diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed index ad902e519..72e740285 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed @@ -236,8 +236,22 @@ class Changed: def graph_text(self, rel): """the text of `rel` the graph's line numbers are in: the tree it was indexed from, uncommitted edits included; None when that was not recorded (no git, an old graph) or the file is not in it""" - if not self.indexed_tree: return None - return sh('git', 'show', f'{self.indexed_tree}:{rel}', cwd=self.repo) + t = sh('git', 'show', f'{self.indexed_tree}:{rel}', cwd=self.repo) if self.indexed_tree else None + if t is not None: return t + # NO RECORDED TREE (a copy without git, a graph older than the record): the file on disk is the text a graph that + # keeps up with edits was built from. Without it every span was placed by name in the older text, and one whose + # name no header there spells stayed at the graph's line, past the end of that text. anchor() checks the spans + # against it and falls back to placing by name when they are not in it + try: return open(os.path.join(self.repo, rel), errors='replace').read() + except OSError: return None + @staticmethod + def header_name(n, d, rel): + """the name a declaration's header spells. A CONSTRUCTOR IS NOT WRITTEN UNDER ITS GRAPH NAME: TypeScript, JavaScript + and C# record it as ``, which no header spells, so looked for by that name a constructor was never found + again (its header changed: "removed"; the graph from a later text: left at the graph's line). Its header says + `constructor` (TypeScript, JavaScript) or its type's name (C#, Java)""" + if n not in ('', '', ''): return n + return 'constructor' if re.search(r'\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$', rel) else d.rsplit('.', 1)[0].rsplit('.', 1)[-1] @staticmethod def declares(text, n, k, is_py): """does this line (strings and comments blanked) DECLARE `n` as a `k`: a def / a typed method header, a class @@ -266,7 +280,8 @@ class Changed: is_py = rel.endswith(('.py', '.pyi')) or bool(re.match(r'#![^\n]*\bpython[0-9.]*\b', O or '')) OL = O.split('\n'); OS = strip_code(O, hash_comments=is_py).split('\n') shaped = lambda k, n: k != 'module' and n not in P.LAMBDA_NAMES - def found(n, k, guess, taken): + def found(n, k, guess, taken, d=''): + n = self.header_name(n, d, rel) c = [j for j in range(1, len(OS) + 1) if j not in taken and self.declares(OS[j - 1], n, k, is_py)] return (min(c, key=lambda j: (abs(j - guess), j)), len(c) > 1) if c else (None, False) def starts(L, a, n): @@ -277,9 +292,9 @@ class Changed: out = []; self.unsure.setdefault(rel, set()) if G is None: for a, b, i, k, d, n in spans: - if not shaped(k, n) or starts(OS, a, n) or (rel.endswith('.cs') and re.match(r'(get|set|add|remove|init)_', n)): + if not shaped(k, n) or starts(OS, a, self.header_name(n, d, rel)) or (rel.endswith('.cs') and re.match(r'(get|set|add|remove|init)_', n)): out.append((a, b, i, k, d, n)); continue - j, many = found(n, k, a, set()) + j, many = found(n, k, a, set(), d) if j is None: out.append((a, b, i, k, d, n)); self.unsure[rel].add(i); continue # nothing better: kept, unsure out.append((j, j + (b - a), i, k, d, n)) if many or rel in self.mismatch: self.unsure[rel].add(i) @@ -315,7 +330,7 @@ class Changed: a2 = exact.get(a) if a2 is None: if not shaped(k, n): continue # a lambda or a module whose first line is gone - a2, many = found(n, k, near.get(a) or a, taken) + a2, many = found(n, k, near.get(a) or a, taken, d) if a2 is None: continue # this text does not declare it if many: self.unsure[rel].add(i) taken.add(a2) @@ -405,6 +420,7 @@ class Changed: res = [] for p in out: p = re.sub(r'=.*$', '', p).strip(); p = re.sub(r'@\w+(\([^)]*\))?\s*', '', p) + p = re.sub(r'^((public|private|protected|readonly|override)\s+)+(?=[A-Za-z_$][\w$]*\s*[?!]?\s*:)', '', p) # a TypeScript parameter property if not p: continue if ':' in p: name, typ = p.split(':', 1)[0].strip(), p.split(':', 1)[1].strip() # TS / Python: name: Type else: @@ -659,6 +675,15 @@ class Changed: return None decorated = {}; stmt_pair = {} OK = strip_code(old, strings=False, hash_comments=is_py).split('\n'); NK = strip_code(new, strings=False, hash_comments=is_py).split('\n') + # A LINE INSERTED INTO A HEADER CHANGES THE SIGNATURE. A parameter list written one parameter a line gains a + # parameter as an inserted line, which no old line was changed for; charged to the callable as an insertion it + # was "body" (a constructor that gained an injected dependency), and its parameter read as an added field. + # The insertion is charged to the header, which is then compared old against new like an edited header line + header_ins = set() + for after, j1, j2 in added: + m = narrowest(after, {'method', 'function', 'constructor'}) if after else None + if m and m[0] <= after < self.header_end(OL, m[0]): + hits.setdefault(('signature', m), set()).add(after); header_ins.add((after, j1, j2)) def counterpart(ln): """the new line an old changed line became: the most similar line of its replaced block; None when deleted""" if ln in removed_lines: return None @@ -769,6 +794,7 @@ class Changed: oh_raw = ' '.join(x.strip() for x in OL[a - 1:self.header_end(OL, a)]); oh = uncomment(OL[a - 1:self.header_end(OL, a)]) na = new_of.get(a) or (new_of.get(max((x for x in new_of if x < a), default=0), 0) + 1) nh = nh_raw = '' + hn = self.header_name(n, d, rel) # `` is spelled `constructor` if na and na <= len(NL): # the new header: from the mapped line, the first line holding the name, to its end for s in range(max(1, na - 2), min(len(NL), na + 6) + 1): @@ -777,8 +803,8 @@ class Changed: code = strip_code(NL[s - 1], strings=False, hash_comments=is_py) # a constructor's name is its type's: the type's own header (`class OrderService {`) two lines # above is not the constructor's new header (#1465) - if k in ('constructor', 'method', 'function') and re.search(rf'\b(class|interface|enum|record|struct)\s+{re.escape(n)}\b', code): continue - if re.search(rf'\b{re.escape(n)}\b', code) and not calls_only(s): + if k in ('constructor', 'method', 'function') and re.search(rf'\b(class|interface|enum|record|struct)\s+{re.escape(hn)}\b', code): continue + if re.search(rf'\b{re.escape(hn)}\b', code) and not calls_only(s): nh_raw = ' '.join(x.strip() for x in NL[s - 1:self.header_end(NL, s)]); nh = uncomment(NL[s - 1:self.header_end(NL, s)]); break # AN EXPRESSION BODY IS NOT HEADER. `int Count() => xs.Count(x => x > 0);` is one line, and read to its `;` # the whole body was header text: an edit inside the lambda it holds came back as a signature change @@ -788,7 +814,7 @@ class Changed: # parameter read as removed ("signature f -self, -rel"). Declared nowhere in the new text, it is removed; # declared elsewhere, what changed cannot be read from here if not nh: - again = still_declared(n, k, na or a) + again = still_declared(hn, k, na or a) if not again: if not any(e['kind'] == 'removed' and e['id'] == i for e in out): entry.update(kind='removed', target=self.target(kind, d, k, n)); out.append(entry) continue @@ -807,7 +833,7 @@ class Changed: op, np_ = self.params(oh_s), self.params(nh_s) if nh_s else [] on, nn = [x for x, _ in op], [x for x, _ in np_] detail = [] - if nh and not re.search(rf'\b{re.escape(n)}\b', nh): detail.append('renamed') + if nh and not re.search(rf'\b{re.escape(hn)}\b', nh): detail.append('renamed') for x in on: if x not in nn: detail.append(f'-{x}') for x in nn: @@ -816,7 +842,7 @@ class Changed: if x == y and t1 != t2 and t1 and t2: detail.append(f'{x}: {t1} → {t2}') pre_o = re.sub(r'\(.*$', '', oh_s); pre_n = re.sub(r'\(.*$', '', nh_s) if nh_s else '' if nh and pre_o.split() != pre_n.split(): - ro = [w for w in pre_o.split() if w != n]; rn = [w for w in pre_n.split() if w != n] + ro = [w for w in pre_o.split() if w != hn]; rn = [w for w in pre_n.split() if w != hn] if ro != rn: detail.append('return type / modifiers: ' + ' '.join(ro) + ' → ' + ' '.join(rn)) if not detail and nh and re.sub(r'\s', '', oh) != re.sub(r'\s', '', nh): detail.append('header text changed (annotations / throws / generics)') if detail and not decs and re.sub(r'\s', '', oh_s) == re.sub(r'\s', '', nh_s) and nh_s: detail = ['decoration changed above the signature (the signature itself is unchanged)'] @@ -910,6 +936,13 @@ class Changed: # "not anchored" it came back as "N new line(s) at file:518" and changed --impact / test-impact never # followed it. The anchor test still decides at the callable's first line, and a module is never a body if owner and owner[3] != 'module' and owner[0] < after < owner[1]: anchored = True + # A PYTHON BODY HAS NO CLOSING LINE: its last statement is the span's last line, and a statement appended + # after it, indented deeper than the def, is still that body ("N new line(s) inside " otherwise, and + # the callable went untested) + if is_py and is_add and owner and owner[3] != 'module' and after == owner[1] and owner[0] <= len(OL): + first = next((NL[j - 1] for j in range(j1, j2 + 1) if NL[j - 1].strip()), '') + hdr = OL[owner[0] - 1] + if first and len(first) - len(first.lstrip()) > len(hdr) - len(hdr.lstrip()): owner = (owner[0], owner[1] + 1) + tuple(owner[2:]); anchored = True in_body = bool(is_add and owner and owner[0] <= after < owner[1] and anchored) if in_body: # strictly inside a callable's body: that callable changed if not any(e['id'] == owner[2] for e in out): @@ -933,6 +966,7 @@ class Changed: tline = next((j for j in range(1, len(NL) + 1) if re.search(rf'\b(class|interface|enum|record|trait|struct)\s+{re.escape(t[5])}\b', NL[j - 1])), None) if tline: body_depth = ndepth[self.header_end(NL, tline)] if not py else len(NL[tline - 1]) - len(NL[tline - 1].lstrip()) + 4 else: body_depth = 0 + toplevel = set() # names declared at the file's top level for j in range(j1, j2 + 1): text = NL[j - 1]; st = re.sub(r'\s*(//.*|/\*.*?\*/\s*)$', '', text).strip() # a line wholly inside a docstring, a string or a comment declares nothing: read as code, a docstring @@ -940,14 +974,29 @@ class Changed: if not SL[j - 1].strip(): continue st = re.sub(r'^(@[\w.]+(\([^)]*\))?\s+)+(?=\w)', '', st) # `@Override public …` on one line here = (ndepth[j - 1] if not py else len(text) - len(text.lstrip())) + # AN INSERTION CAN RUN PAST THE TYPE IT BEGAN IN: new methods at the end of a class and a function after + # it are one block (in Python always, having no closing line). A function at the file's top level is + # its own declaration, owned by no type; read at the type's body level only, it was not named at all + fm = t and here == 0 and body_depth > 0 and re.match(r'^(?:export\s+(?:default\s+)?)?(?:async\s+)?(?:def|function\*?)\s+([A-Za-z_$][\w$]*)\s*(?:<[^()]*>)?\s*\(', st) + if fm and not any(x[0] == fm.group(1) for x in names): + names.append((fm.group(1), 'function', j)); toplevel.add(fm.group(1)); hdrs[fm.group(1)] = ' '.join(x.strip() for x in NL[j - 1:self.header_end(NL, j)]); continue if here != body_depth or not st or st.startswith(('//', '*', '/*', '@', '#', 'return', 'if', 'for', 'while', 'switch', 'else', 'throw', 'new ', 'case', 'try', 'catch')): continue # a file's own header lines declare nothing: `package a.b.c;` and `import a.b.C;` read as a field `c` / `C` if re.match(r'(package|import|using|namespace|from|module|export\s+\*)\b', st): continue - tm = re.match(r'^(?:(?:public|private|protected|static|final|abstract|sealed)\s+)*(class|interface|enum|record|@interface)\s+([A-Za-z_$][\w$]*)', st) + # a parameter inserted into a header (header_ins) is the signature's change: it declares a field only as + # a TypeScript parameter property (`private readonly x: T`) + if (after, j1, j2) in header_ins: + ty, nm = self.field_parts(st.rstrip(',')) + if nm and re.match(r'(public|private|protected|readonly)\b', st) and not any(x[0] == nm for x in names): names.append((nm, 'field', j)) + continue + tm = re.match(r'^(?:(?:public|private|protected|static|final|abstract|sealed|export|default|declare)\s+)*(class|interface|enum|record|@interface)\s+([A-Za-z_$][\w$]*)', st) if tm: names.append((tm.group(2), tm.group(1).lstrip('@') + ' (with its members)', j)); hdrs[tm.group(2)] = st; continue - m = re.match(r'^(?:def|async def|function|async function)\s+([A-Za-z_$][\w$]*)\s*\(', st) or (not py and ( - re.match(r'^(?:[\w<>\[\],.?$ ]+\s+)?([A-Za-z_$][\w$]*)\s*\([^;=]*\)\s*(?:throws[\w.,\s]+)?\s*(\{|;|$)', st) or \ - re.match(r'^(?:[\w<>\[\],.?$ ]+\s+)?([A-Za-z_$][\w$]*)\s*\([^;={]*$', st))) + # a TypeScript header writes its return type after the parameters (`f(a: A): Promise {`), and a + # module's function is often exported (`export async function f(`): read as neither, a new method or + # function was "N new line(s)" with no name, and a local inside it could pass for a field + m = re.match(r'^(?:export\s+(?:default\s+)?)?(?:def|async def|function\*?|async function\*?)\s+([A-Za-z_$][\w$]*)\s*(?:<[^()]*>)?\s*\(', st) or (not py and ( + re.match(r'^(?:[\w<>\[\],.?$ ]+\s+)?([A-Za-z_$][\w$]*)\s*(?:<[^()]*>)?\s*\([^;=]*\)\s*(?::\s*[^;{}=]+?)?\s*(?:throws[\w.,\s]+)?\s*(\{|;|$)', st) or \ + re.match(r'^(?:[\w<>\[\],.?$ ]+\s+)?([A-Za-z_$][\w$]*)\s*(?:<[^()]*>)?\s*\([^;={]*$', st))) if m and not re.match(r'^(if|for|while|switch|catch|synchronized|return|new|super|this|else)$', m.group(1)) and '=' not in st.split('(')[0]: names.append((m.group(1), 'method', j)); hdrs[m.group(1)] = ' '.join(x.strip() for x in NL[j - 1:self.header_end(NL, j)]); continue # each declaration on the line: `int a = 1; int b = 2;` and `int a = 1, b = 2;` declare two fields @@ -964,7 +1013,7 @@ class Changed: tt = typ or name # a signature spells types only out.append(re.sub(r'<.*', '', tt.split('.')[-1]).replace('...', '[]').strip()) return out - def old_has(nm, hdr): + def old_has(nm, hdr, t=t): ht = types_of(hdr) if '(' in hdr else None for a, b, i, k, d, n in decls: if n != nm or (t and not d.startswith(t[4] + '.') and d != t[4] + '.' + nm and k not in ('class', 'interface', 'enum')): continue @@ -980,14 +1029,22 @@ class Changed: if ('...' in old_hdr) != ('...' in hdr): continue return True return False + decl_ids = {x[2] for x in decls} + t_in = t for nm, k, jn in names: - if old_has(nm, hdrs.get(nm, '')): continue + t = None if nm in toplevel else t_in + if old_has(nm, hdrs.get(nm, ''), t): continue + if k == 'method' and not t: k = 'function' # declared at a file's top level # an added method whose name already exists on this owner is an OVERLOAD: the compiler may rebind # existing call sites of that name to it, and a call site records no argument types, so which of them # rebind cannot be decided here. Never claim nothing depends on it sib = [] if k in ('method', 'function', 'constructor') and t: for i, sy in self.g.sym.items(): + # an overload is of a declaration the OLD text has: the graph can be from a later text (a range, + # a refresh), where this very method is declared, and its own callers were named as the calls + # an overload might take over. In this file, only what the old text declares (decls) counts + if sy.get('file') == rel and i not in decl_ids: continue if sy.get('method_id') and sy['name'] == nm and (sy['owner'] or '') == t[4]: sib += [r[0] for r in self.g.q("SELECT DISTINCT s.display FROM call_edges e JOIN symbols s ON s.id = e.caller_id WHERE e.callee_method_id = ?", sy['method_id'])] note = f'new {k}' @@ -1003,6 +1060,7 @@ class Changed: if mm and mm.group(1) not in ('if', 'for', 'while', 'switch', 'catch', 'return', 'new', 'super', 'this'): out.append(dict(kind='added', symbol=f"{(t[4] + '.' if t else '')}{nm}.{mm.group(1)}", id=None, file=rel, line=j - 1, end=j - 1, old_lines=[], detail='new method', target=None)); continue ty2, nm2 = self.field_parts(st2) if nm2 and ty2 and st2.endswith(';') and '(' not in st2.split('=')[0]: out.append(dict(kind='added', symbol=f"{(t[4] + '.' if t else '')}{nm}.{nm2}", id=None, file=rel, line=j - 1, end=j - 1, old_lines=[], detail='new field', target=None)) + t = t_in if not names and is_add and not in_body: adds.append((rel, f"{j2 - j1 + 1} new line(s) at {rel}:{j1}" + (f" inside {t[4]}" if t else ''), None)) # A DECLARATION WHOSE SIGNATURE CHANGED IS NOT A NEW ONE. `added` is decided by whether the OLD text has a # header that matches, so editing the header itself — `def f(a)` to `def f(a, *, cap=0)`, or `def` to diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index 0cd36133a..cb8f671c2 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -77,9 +77,10 @@ BODILESS_KINDS = {'METHOD_SIGNATURE', 'TYPE_LITERAL_METHOD_SIGNATURE', 'CALL_SIG 'FUNCTION_TYPE_SIGNATURE', 'CONSTRUCT_SIGNATURE', 'TYPE_LITERAL_CONSTRUCT_SIGNATURE', 'CONSTRUCTOR_TYPE_SIGNATURE'} # of those, the ones a call through a VALUE lands on: a function type or a bare call signature, not a member of an interface FUNCTION_TYPE_KINDS = {'FUNCTION_TYPE_SIGNATURE', 'CALL_SIGNATURE', 'TYPE_LITERAL_CALL_SIGNATURE'} -# the one name a front end gives every lambda it declares (Python and C#; Java declares none): a name that says nothing -# about WHICH lambda, so it is never a target on its own (G.lambda_label, G.lambda_target) -LAMBDA_NAMES = {''} +# the one name a front end gives every lambda it declares (Python and C# ``, C# ``, TypeScript +# and JavaScript `` / ``; Java declares none): a name that says nothing about WHICH lambda, +# so it is never a target on its own (G.lambda_label, G.lambda_target) +LAMBDA_NAMES = {'', '', '', ''} # what a front end synthesises ON a field's line that is the field's own, never a callable written there (G.decl_at_line): # the node that runs a class's field initializers, and the unnamed function an initializer holds (`cb = wrap(() => …)`), # which JavaScript and TypeScript name `` / `` where Python and C# say `` diff --git a/tests/cases/csharp/grown-file-read-against-its-base/case.json b/tests/cases/csharp/grown-file-read-against-its-base/case.json new file mode 100644 index 000000000..2b5aadfe6 --- /dev/null +++ b/tests/cases/csharp/grown-file-read-against-its-base/case.json @@ -0,0 +1,29 @@ +{ + "lang": "csharp", + "src": "src", + "checks": [ + { + "why": "a graph from a later text than the base: a parameter inserted into a constructor header written one per line (a C# constructor is recorded as ``, its header spells the type's name) is its signature change, not its body and not a removal; a new method with a generic return type is named as added, and a local in it is no field", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/old.cs", + "--new", + "{repo}/new.cs", + "--file", + "src/Service.cs" + ], + "want": [ + "signature Service.", + "+gateway", + "added Service.Pay" + ], + "avoid": [ + "removed", + "Service.total", + "added Service.gateway src/Service.cs:11" + ] + } + ] +} \ No newline at end of file diff --git a/tests/cases/csharp/grown-file-read-against-its-base/new.cs b/tests/cases/csharp/grown-file-read-against-its-base/new.cs new file mode 100644 index 000000000..43150ab50 --- /dev/null +++ b/tests/cases/csharp/grown-file-read-against-its-base/new.cs @@ -0,0 +1,33 @@ +namespace App +{ + public class Service + { + private readonly Repo repo; + + private readonly Gateway gateway; + + public Service( + Repo repo, + Gateway gateway, + Clock clock) + { + this.repo = repo; + this.gateway = gateway; + } + + public int Get(string id) + { + return repo.Find(id); + } + + public async System.Threading.Tasks.Task Pay(int n) + { + var total = n; + return await gateway.Pay(total); + } + } + + public class Repo { public int Find(string id) { return 0; } } + public class Clock { } + public class Gateway { public System.Threading.Tasks.Task Pay(int n) { return System.Threading.Tasks.Task.FromResult(n); } } +} diff --git a/tests/cases/csharp/grown-file-read-against-its-base/old.cs b/tests/cases/csharp/grown-file-read-against-its-base/old.cs new file mode 100644 index 000000000..0a3b5f0e0 --- /dev/null +++ b/tests/cases/csharp/grown-file-read-against-its-base/old.cs @@ -0,0 +1,19 @@ +namespace App +{ + public class Service + { + private readonly Repo repo; + + public Service( + Repo repo, + Clock clock) + { + this.repo = repo; + } + + public int Get(string id) + { + return repo.Find(id); + } + } +} diff --git a/tests/cases/csharp/grown-file-read-against-its-base/src/Service.cs b/tests/cases/csharp/grown-file-read-against-its-base/src/Service.cs new file mode 100644 index 000000000..43150ab50 --- /dev/null +++ b/tests/cases/csharp/grown-file-read-against-its-base/src/Service.cs @@ -0,0 +1,33 @@ +namespace App +{ + public class Service + { + private readonly Repo repo; + + private readonly Gateway gateway; + + public Service( + Repo repo, + Gateway gateway, + Clock clock) + { + this.repo = repo; + this.gateway = gateway; + } + + public int Get(string id) + { + return repo.Find(id); + } + + public async System.Threading.Tasks.Task Pay(int n) + { + var total = n; + return await gateway.Pay(total); + } + } + + public class Repo { public int Find(string id) { return 0; } } + public class Clock { } + public class Gateway { public System.Threading.Tasks.Task Pay(int n) { return System.Threading.Tasks.Task.FromResult(n); } } +} diff --git a/tests/cases/javascript/grown-file-read-against-its-base/case.json b/tests/cases/javascript/grown-file-read-against-its-base/case.json new file mode 100644 index 000000000..86e5c8ec6 --- /dev/null +++ b/tests/cases/javascript/grown-file-read-against-its-base/case.json @@ -0,0 +1,33 @@ +{ + "lang": "javascript", + "src": "src", + "checks": [ + { + "why": "a graph from a later text than the base: a parameter inserted into a constructor header is its signature change; an edit inside an arrow passed from a method is that method's body, not an `` of its own; a new method and an exported async function are named, and a local in the function is no field of the class", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/old.js", + "--new", + "{repo}/new.js", + "--file", + "src/service.js" + ], + "want": [ + "signature Service.", + "+gateway", + "added Service.pay", + "added report", + "body Service.get" + ], + "avoid": [ + "removed", + "Service.base", + "Service.total", + "", + "" + ] + } + ] +} \ No newline at end of file diff --git a/tests/cases/javascript/grown-file-read-against-its-base/new.js b/tests/cases/javascript/grown-file-read-against-its-base/new.js new file mode 100644 index 000000000..2536f70f0 --- /dev/null +++ b/tests/cases/javascript/grown-file-read-against-its-base/new.js @@ -0,0 +1,26 @@ +export class Service { + constructor( + repo, + clock, + gateway, + ) { + this.repo = repo; + this.gateway = gateway; + } + + get(id) { + return this.repo.run(async () => { + return this.pay(this.repo.find(id)); + }); + } + + async pay(n) { + const total = n; + return this.gateway.pay(total); + } +} + +export async function report(event) { + const base = { id: event.id }; + return base; +} diff --git a/tests/cases/javascript/grown-file-read-against-its-base/old.js b/tests/cases/javascript/grown-file-read-against-its-base/old.js new file mode 100644 index 000000000..ec27fd941 --- /dev/null +++ b/tests/cases/javascript/grown-file-read-against-its-base/old.js @@ -0,0 +1,14 @@ +export class Service { + constructor( + repo, + clock, + ) { + this.repo = repo; + } + + get(id) { + return this.repo.run(async () => { + return this.repo.find(id); + }); + } +} diff --git a/tests/cases/javascript/grown-file-read-against-its-base/src/service.js b/tests/cases/javascript/grown-file-read-against-its-base/src/service.js new file mode 100644 index 000000000..2536f70f0 --- /dev/null +++ b/tests/cases/javascript/grown-file-read-against-its-base/src/service.js @@ -0,0 +1,26 @@ +export class Service { + constructor( + repo, + clock, + gateway, + ) { + this.repo = repo; + this.gateway = gateway; + } + + get(id) { + return this.repo.run(async () => { + return this.pay(this.repo.find(id)); + }); + } + + async pay(n) { + const total = n; + return this.gateway.pay(total); + } +} + +export async function report(event) { + const base = { id: event.id }; + return base; +} diff --git a/tests/cases/python/grown-file-read-against-its-base/case.json b/tests/cases/python/grown-file-read-against-its-base/case.json new file mode 100644 index 000000000..53b228d90 --- /dev/null +++ b/tests/cases/python/grown-file-read-against-its-base/case.json @@ -0,0 +1,52 @@ +{ + "lang": "python", + "src": "src", + "checks": [ + { + "why": "a graph from a later text than the base: a parameter inserted into a def header written one per line is its signature change; new methods at the end of a class and a function after it are one inserted block, and the function is named at the top level, not dropped nor read as a member", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/old.py", + "--new", + "{repo}/new.py", + "--file", + "src/service.py" + ], + "want": [ + "signature Service.__init__", + "+gateway", + "added Service.pay", + "added report" + ], + "avoid": [ + "removed", + "Service.total", + "Service.base", + "Service.report", + "new line(s)" + ] + }, + { + "why": "control: a method added after a class's last method, at the same indentation, is not that method's body; a line appended inside `__init__` is", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/old.py", + "--new", + "{repo}/new.py", + "--file", + "src/service.py" + ], + "want": [ + "signature Service.__init__" + ], + "avoid": [ + "body Service.get", + "inside Service" + ] + } + ] +} \ No newline at end of file diff --git a/tests/cases/python/grown-file-read-against-its-base/new.py b/tests/cases/python/grown-file-read-against-its-base/new.py new file mode 100644 index 000000000..956f13813 --- /dev/null +++ b/tests/cases/python/grown-file-read-against-its-base/new.py @@ -0,0 +1,21 @@ +class Service: + def __init__( + self, + repo, + clock, + gateway, + ): + self.repo = repo + self.gateway = gateway + + def get(self, id): + return self.repo.find(id) + + async def pay(self, n) -> int: + total = n + return await self.gateway.pay(total) + + +def report(event): + base = {"id": event.id} + return base diff --git a/tests/cases/python/grown-file-read-against-its-base/old.py b/tests/cases/python/grown-file-read-against-its-base/old.py new file mode 100644 index 000000000..22a2a7343 --- /dev/null +++ b/tests/cases/python/grown-file-read-against-its-base/old.py @@ -0,0 +1,10 @@ +class Service: + def __init__( + self, + repo, + clock, + ): + self.repo = repo + + def get(self, id): + return self.repo.find(id) diff --git a/tests/cases/python/grown-file-read-against-its-base/src/service.py b/tests/cases/python/grown-file-read-against-its-base/src/service.py new file mode 100644 index 000000000..956f13813 --- /dev/null +++ b/tests/cases/python/grown-file-read-against-its-base/src/service.py @@ -0,0 +1,21 @@ +class Service: + def __init__( + self, + repo, + clock, + gateway, + ): + self.repo = repo + self.gateway = gateway + + def get(self, id): + return self.repo.find(id) + + async def pay(self, n) -> int: + total = n + return await self.gateway.pay(total) + + +def report(event): + base = {"id": event.id} + return base diff --git a/tests/cases/typescript/grown-file-read-against-its-base/body-only.ts b/tests/cases/typescript/grown-file-read-against-its-base/body-only.ts new file mode 100644 index 000000000..416d6ef8e --- /dev/null +++ b/tests/cases/typescript/grown-file-read-against-its-base/body-only.ts @@ -0,0 +1,23 @@ +import { Clock, Repo } from './repo'; + +export class Service { + private readonly cache: Map; + + constructor( + private readonly repo: Repo, + clock: Clock, + ) { + this.cache = new Map([["", 0]]); + } + + place(id: string): Promise { + return this.repo.run(async () => { + const n = await this.repo.find(id); + return n; + }); + } + + get(id: string): number { + return this.cache.get(id) ?? 0; + } +} diff --git a/tests/cases/typescript/grown-file-read-against-its-base/case.json b/tests/cases/typescript/grown-file-read-against-its-base/case.json new file mode 100644 index 000000000..17f72283b --- /dev/null +++ b/tests/cases/typescript/grown-file-read-against-its-base/case.json @@ -0,0 +1,97 @@ +{ + "lang": "typescript", + "src": "src", + "checks": [ + { + "why": "the graph is from a later text of the file than the base it is read against (a range, a refresh): parameters inserted into a constructor header written one per line are its signature change, not its body, and a plain parameter is no field", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/old.ts", + "--new", + "{repo}/new.ts", + "--file", + "src/service.ts" + ], + "want": [ + "signature Service.", + "+logger", + "+gateway" + ], + "avoid": [ + "body Service.", + "removed", + "added Service.logger", + "Traceback" + ] + }, + { + "why": "methods with a TypeScript return type and an exported module function are named as added; a local inside the new function is not a field of the class, and a declaration only the later graph holds is not an overload of itself", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/old.ts", + "--new", + "{repo}/new.ts", + "--file", + "src/service.ts" + ], + "want": [ + "added Service.apply", + "added Service.authorize", + "added reportFrom", + "new function", + "added Service.label", + "added Service.gateway" + ], + "avoid": [ + "Service.base", + "overload", + "new line(s)", + "removed or renamed" + ] + }, + { + "why": "an edit inside an arrow passed from a method is that method's body, not an `` of its own", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/old.ts", + "--new", + "{repo}/new.ts", + "--file", + "src/service.ts" + ], + "want": [ + "body Service.place" + ], + "avoid": [ + "" + ] + }, + { + "why": "control: an edit to the constructor's body alone stays a body change, and adds nothing", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/old.ts", + "--new", + "{repo}/body-only.ts", + "--file", + "src/service.ts" + ], + "want": [ + "body Service." + ], + "avoid": [ + "signature Service", + "added ", + "removed" + ] + } + ] +} \ No newline at end of file diff --git a/tests/cases/typescript/grown-file-read-against-its-base/new.ts b/tests/cases/typescript/grown-file-read-against-its-base/new.ts new file mode 100644 index 000000000..dbde37589 --- /dev/null +++ b/tests/cases/typescript/grown-file-read-against-its-base/new.ts @@ -0,0 +1,41 @@ +import { Clock, Gateway, Logger, Repo, Event, Report } from './repo'; + +export class Service { + private readonly cache: Map; + + private readonly label = 'service'; + + constructor( + private readonly repo: Repo, + clock: Clock, + logger: Logger, + private readonly gateway: Gateway, + ) { + this.cache = new Map(); + } + + place(id: string): Promise { + return this.repo.run(async () => { + const n = await this.repo.find(id); + return this.authorize(n); + }); + } + + async apply(event: Event): Promise<'applied' | 'ignored'> { + const n = await this.repo.find(event.id); + return n ? 'applied' : 'ignored'; + } + + private async authorize(n: number): Promise { + return this.gateway.pay(n); + } + + get(id: string): number { + return this.cache.get(id) ?? 0; + } +} + +export function reportFrom(event: Event): Report { + const base = { id: event.id }; + return { ...base }; +} diff --git a/tests/cases/typescript/grown-file-read-against-its-base/old.ts b/tests/cases/typescript/grown-file-read-against-its-base/old.ts new file mode 100644 index 000000000..c777ce7b2 --- /dev/null +++ b/tests/cases/typescript/grown-file-read-against-its-base/old.ts @@ -0,0 +1,23 @@ +import { Clock, Repo } from './repo'; + +export class Service { + private readonly cache: Map; + + constructor( + private readonly repo: Repo, + clock: Clock, + ) { + this.cache = new Map(); + } + + place(id: string): Promise { + return this.repo.run(async () => { + const n = await this.repo.find(id); + return n; + }); + } + + get(id: string): number { + return this.cache.get(id) ?? 0; + } +} diff --git a/tests/cases/typescript/grown-file-read-against-its-base/src/repo.ts b/tests/cases/typescript/grown-file-read-against-its-base/src/repo.ts new file mode 100644 index 000000000..352e689e4 --- /dev/null +++ b/tests/cases/typescript/grown-file-read-against-its-base/src/repo.ts @@ -0,0 +1,9 @@ +export interface Clock { now(): number } +export interface Logger { warn(m: string): void } +export interface Gateway { pay(n: number): Promise } +export interface Event { id: string } +export interface Report { id: string } +export interface Repo { + run(f: () => Promise): Promise; + find(id: string): Promise; +} diff --git a/tests/cases/typescript/grown-file-read-against-its-base/src/service.ts b/tests/cases/typescript/grown-file-read-against-its-base/src/service.ts new file mode 100644 index 000000000..dbde37589 --- /dev/null +++ b/tests/cases/typescript/grown-file-read-against-its-base/src/service.ts @@ -0,0 +1,41 @@ +import { Clock, Gateway, Logger, Repo, Event, Report } from './repo'; + +export class Service { + private readonly cache: Map; + + private readonly label = 'service'; + + constructor( + private readonly repo: Repo, + clock: Clock, + logger: Logger, + private readonly gateway: Gateway, + ) { + this.cache = new Map(); + } + + place(id: string): Promise { + return this.repo.run(async () => { + const n = await this.repo.find(id); + return this.authorize(n); + }); + } + + async apply(event: Event): Promise<'applied' | 'ignored'> { + const n = await this.repo.find(event.id); + return n ? 'applied' : 'ignored'; + } + + private async authorize(n: number): Promise { + return this.gateway.pay(n); + } + + get(id: string): number { + return this.cache.get(id) ?? 0; + } +} + +export function reportFrom(event: Event): Report { + const base = { id: event.id }; + return { ...base }; +} diff --git a/tests/cases/typescript/indexed-with-uncommitted-edit/case.json b/tests/cases/typescript/indexed-with-uncommitted-edit/case.json index ed69002ae..bbf9d68e7 100644 --- a/tests/cases/typescript/indexed-with-uncommitted-edit/case.json +++ b/tests/cases/typescript/indexed-with-uncommitted-edit/case.json @@ -3,7 +3,7 @@ "src": "src", "checks": [ { - "why": "a graph indexed from a working tree with uncommitted edits has spans in that text's line numbers; against the committed file they can run past its end, and `changed` (and test-impact, which calls it) crashed with an IndexError. It now reads the file clamped and says the positions may be off until a re-index", + "why": "a graph indexed from a working tree with uncommitted edits has spans in that text's line numbers; against the committed file they can run past its end, and `changed` (and test-impact, which calls it) crashed with an IndexError. With no recorded tree, the file on disk is the text the spans are checked against and carried from, so the edit is read exactly: `a` gained a parameter", "run": [ "changed", "{repo}", @@ -16,13 +16,16 @@ ], "want": [ "changed declarations", - "indexed from a working tree with uncommitted edits" + "signature a", + "+strict" ], "avoid": [ "Traceback", "IndexError", - "added src/app.ts: the graph" + "added src/app.ts: the graph", + "signature b", + "body b" ] } ] -} +} \ No newline at end of file