diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index 0cd36133..fa1d805f 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -77,9 +77,11 @@ 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 names a front end gives every unnamed function it declares: `` (Python and C#), `` (C# +# `delegate (…) { … }`), `` and `` (JavaScript and TypeScript); Java declares none. A name +# that says nothing about WHICH one, so it is never a target on its own (G.lambda_label, G.lambda_target): taken as +# one, an edit inside one arrow was every arrow of the repository, and a body edit came back as "removed " +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/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py index ce5ae73f..549f6ebd 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py @@ -187,28 +187,40 @@ def _at(f_, l_): stubs = ax_edges.stub_sites(lambda s_, p_: q(s_, p_).fetchall()) con.execute("CREATE TEMP TABLE _stub(id TEXT PRIMARY KEY)") con.executemany("INSERT OR IGNORE INTO _stub VALUES(?)", [(x,) for x in stubs]) - _reads = collections.defaultdict(set) - for d, tier, sid in q(f"""SELECT DISTINCT s.display, ce.tier, ce.call_site_id FROM call_edges ce JOIN symbols s ON s.id=ce.caller_id + # A CALLER IS ITS ID, NOT ITS DISPLAY. Every unnamed function of a file carries one display (``, + # ``), and so does every module body of one basename (`app.`): keyed by display, the arrow in a + # test and the arrow in the source that really calls this were one row, located at whichever came first in the + # table ("called by event-bus.js:52" for a caller in a test file) + _reads = collections.defaultdict(set); _disp = {}; _sites = collections.defaultdict(list) + for c, d, tier, sid in q(f"""SELECT DISTINCT s.id, s.display, ce.tier, ce.call_site_id FROM call_edges ce JOIN symbols s ON s.id=ce.caller_id WHERE ce.callee_method_id IN ({ph})""", ids): - _reads[d].add(ax_edges.direct_cert(ax_edges.STUB_TIER if sid in stubs else tier)) + cert = ax_edges.direct_cert(ax_edges.STUB_TIER if sid in stubs else tier) + _reads[c].add(cert); _disp[c] = d + if sid: _sites[c].append((cert, sid)) # …and the end a FRAMEWORK hands it over from (#1509): a task's .delay() producer, a signal's sender, a route # table, a Depends() default. The rules list it as a `framework` dependent, so this does too, or the hook # line and `impact` name different dependents for the same declaration. A direct row only, as in the rules: # it does not enter the walk below. if 'ext_framework_edge' in _tables(con): - for (d,) in q(f"""SELECT DISTINCT s.display FROM ext_framework_edge f JOIN symbols s ON s.id=f.c0 + for c, d in q(f"""SELECT DISTINCT s.id, s.display FROM ext_framework_edge f JOIN symbols s ON s.id=f.c0 WHERE f.c1 IN ({ph}) AND f.c0 <> f.c1""", ids): - _reads[d].add('framework') - read_cert = {d: ax_edges.best_cert(cs) for d, cs in _reads.items()} - reads = sorted(_reads) + _reads[c].add('framework'); _disp[c] = d + read_cert = {c: ax_edges.best_cert(cs) for c, cs in _reads.items()} # by caller id + # where each caller is located: its call sites of its surest certainty, as the rules pick (lowest of those) + sites = {c: [s_ for ct, s_ in v if ct == read_cert[c]] for c, v in _sites.items()} + read_ids = sorted(_reads, key=lambda c: (_disp[c] or '', c)) + reads = [_disp[c] for c in read_ids] # reads / uses it, by name: a site naming this method whose receiver the engine could not type. The parser # records callee_name and the bundle indexes it, so this is a lookup and not an inference. short = target.rsplit('.', 1)[-1] if at_line(target.strip()): # file:line: the name its declaration carries short = (q("SELECT name FROM symbols WHERE id=?", (ids[0],)).fetchone() or [short])[0] - byname = sorted({r[0] for r in q( - """SELECT DISTINCT s.display FROM call_sites cs JOIN unresolved_sites us ON us.call_site_id=cs.id - JOIN symbols s ON s.id=cs.caller_id WHERE cs.callee_name=? AND cs.id NOT IN (SELECT id FROM _stub)""", (short,))} - set(reads)) + _bn = {} + for c, d, sid in q("""SELECT DISTINCT s.id, s.display, cs.id FROM call_sites cs JOIN unresolved_sites us ON us.call_site_id=cs.id + JOIN symbols s ON s.id=cs.caller_id WHERE cs.callee_name=? AND cs.id NOT IN (SELECT id FROM _stub)""", (short,)): + if c in _reads: continue + _bn[c] = d; sites.setdefault(c, []).append(sid) + byname_ids = sorted(_bn, key=lambda c: (_bn[c] or '', c)); byname = [_bn[c] for c in byname_ids] # the two counts. Each edge table joins in its OWN recursive branch so SQLite drives them by index; building # one combined edge CTE first scans all 608k edges per call (1.89 s against 0.02 s for the same answer). # THE DISPATCH HOP IS NARROWED, the same way the RULES narrow it. `edge.facts` is written by @@ -276,10 +288,12 @@ def _at(f_, l_): if param: # the rules put the declaring method in `direct` as "declares it" — it is the declaration the edit # is inside, so omitting it under-reports by the one row the caller is certain to care about - own = sorted({r[0] for r in q(f"SELECT display FROM symbols WHERE id IN ({ph})", ids) if r[0]}) - reads = sorted(set(reads) | set(own)) - byname = sorted(set(byname) - set(reads)) - return dict(target=target, overloads=len(ids), contract=contract, reads=reads, read_cert=read_cert, byname=byname, + own = {r[0]: r[1] for r in q(f"SELECT id, display FROM symbols WHERE id IN ({ph})", ids) if r[1]} + _disp.update(own) + read_ids = sorted(set(read_ids) | set(own), key=lambda c: (_disp[c] or '', c)); reads = [_disp[c] for c in read_ids] + byname_ids = [c for c in byname_ids if c not in own]; byname = [_bn[c] for c in byname_ids] + return dict(target=target, overloads=len(ids), contract=contract, reads=reads, read_ids=read_ids, read_cert=read_cert, + byname=byname, byname_ids=byname_ids, sites=sites, reached=max(0, n - len(ids)), tests=t, test_names=test_names, test_ids=sorted(tset), depth=depth) finally: con.close() @@ -321,19 +335,31 @@ def impact_shaped(repo, target, depth=DEPTH, tests_shown=3, file=None): # only the rows that get PRINTED need a location: the formatter shows 4 per line. Resolving file:line for # every display cost 5.7 s against 1.7 s on a target with 739 callers, to fill in text nobody sees. SHOWN = 8 - every = r['contract'][:SHOWN] + r['reads'][:SHOWN] + r['byname'][:SHOWN] at = {} - if every: - for i in range(0, len(every), 400): - chunk = every[i:i + 400] - for d, f, ln in q(f"SELECT display, file, line FROM symbols WHERE display IN ({','.join('?'*len(chunk))})", chunk): - if d not in at and f: at[d] = f"{f}:{ln or 0}" - def mk(d, role, cert): + # a contract row is a display (an override has a name of its own); a caller is located by its id, since an + # unnamed function or a module body shares its display with every other one (impact above) + for d, f, ln in (q(f"SELECT display, file, line FROM symbols WHERE display IN ({','.join('?' * len(r['contract'][:SHOWN]))})", + r['contract'][:SHOWN]) if r['contract'] else ()): + if d not in at and f: at[d] = f"{f}:{ln or 0}" + # …at its LOWEST CALL SITE, the line the rules print (axiomcode-impact: the first site, of the surest certainty), + # in the caller's own file (a site is written in its caller; Java stores the site's path absolute); a row with + # no site (a framework hand-off) at its declaration + ids_ = r['read_ids'][:SHOWN] + r['byname_ids'][:SHOWN] + decl = {i: (f, ln) for i, f, ln in (q(f"SELECT id, file, line FROM symbols WHERE id IN ({','.join('?' * len(ids_))})", ids_) if ids_ else ()) if f} + by_site = {s_: i for i in ids_ for s_ in r['sites'].get(i, ())} + low = {} + for j in range(0, len(by_site), 400): + chunk = list(by_site)[j:j + 400] + for sid, ln in q(f"SELECT id, start_line FROM call_sites WHERE id IN ({','.join('?' * len(chunk))})", chunk): + if ln and int(ln) > 0: low[by_site[sid]] = min(int(ln), low.get(by_site[sid], int(ln))) + at_id = {i: f"{f}:{low.get(i) or ln or 0}" for i, (f, ln) in decl.items()} + def mk(d, i, role, cert): why = ('calls a method of this name (receiver not typed)' if cert == 'by name' else ax_edges.DIRECT_WHY.get(cert, 'calls it')) - return dict(display=d, role=role, certainty=cert, at=at.get(d, ''), why=why) + return dict(display=d, role=role, certainty=cert, at=at_id.get(i, ''), why=why) rc = r.get('read_cert') or {} - direct = [mk(d, 'uses', rc.get(d, 'resolved')) for d in r['reads']] + [mk(d, 'uses', 'by name') for d in r['byname']] + direct = [mk(d, i, 'uses', rc.get(i, 'resolved')) for d, i in zip(r['reads'], r['read_ids'])] + \ + [mk(d, i, 'uses', 'by name') for d, i in zip(r['byname'], r['byname_ids'])] tests = [dict(display=d, owner=d.rsplit('.', 1)[0] if '.' in d else d, name=d.rsplit('.', 1)[-1]) for d in r.get('test_names', [])[:tests_shown]] tests += [dict(display='', owner='', name='')] * max(0, r['tests'] - len(tests)) diff --git a/tests/cases/javascript/lambda-is-named-by-its-place/case.json b/tests/cases/javascript/lambda-is-named-by-its-place/case.json new file mode 100644 index 00000000..32a35344 --- /dev/null +++ b/tests/cases/javascript/lambda-is-named-by-its-place/case.json @@ -0,0 +1,38 @@ +{"lang": "javascript", "src": "src", + "checks": [ + {"why": "an edit inside an arrow in a method body is a body change of THAT METHOD: every arrow carries one name, , which is never the declaration an edit is charged to (its header was looked for by that name, not found, and the arrow came back removed)", + "run": ["changed", "{repo}", "--old", "{repo}/old.txt", "--new", "{repo}/new-in-arrow.txt", "--file", "src/lib.js"], + "want": ["body Orders.totals", "→ impact src/lib.js:4"], + "avoid": ["", "impact src/lib.js:14"], + "avoid": ["removed", "impact "]}, + {"why": "control: an edit to a named function next to the arrows stays that function's", + "run": ["changed", "{repo}", "--old", "{repo}/old.txt", "--new", "{repo}/new-named.txt", "--file", "src/lib.js"], + "want": ["body bump"], + "avoid": ["wire", ""], + "avoid": ["change: ", ""]}, + {"why": "and a function expression the same way", + "run": ["impact", "src/lib.js:6"], + "want": ["change: Orders.totals."], + "avoid": ["change: ", ""]}, + {"why": "the name printed for an arrow is a target that answers for that arrow alone", + "run": ["impact", "Orders.totals."], + "want": ["change: Orders.totals."], + "avoid": ["declarations", "change: "]}]} diff --git a/tests/cases/javascript/lambda-is-named-by-its-place/new-in-arrow.txt b/tests/cases/javascript/lambda-is-named-by-its-place/new-in-arrow.txt new file mode 100644 index 00000000..e73c9f1d --- /dev/null +++ b/tests/cases/javascript/lambda-is-named-by-its-place/new-in-arrow.txt @@ -0,0 +1,18 @@ +export class Orders { + constructor() { this.items = []; } + + totals(xs) { + const keep = xs.filter((x) => x >= 0); + const twice = keep.map(function (y) { return y * 2; }); + return twice; + } + + names() { return this.items.map((i) => i.name); } +} + +export function bump(x) { return x + 1; } +register((xs) => xs.length); + +export function wire(list) { return list.map((x) => bump(x)); } + +function register(fn) { return fn; } diff --git a/tests/cases/javascript/lambda-is-named-by-its-place/new-in-function-expression.txt b/tests/cases/javascript/lambda-is-named-by-its-place/new-in-function-expression.txt new file mode 100644 index 00000000..1df20e43 --- /dev/null +++ b/tests/cases/javascript/lambda-is-named-by-its-place/new-in-function-expression.txt @@ -0,0 +1,18 @@ +export class Orders { + constructor() { this.items = []; } + + totals(xs) { + const keep = xs.filter((x) => x > 0); + const twice = keep.map(function (y) { return y * 3; }); + return twice; + } + + names() { return this.items.map((i) => i.name); } +} + +export function bump(x) { return x + 1; } +register((xs) => xs.length); + +export function wire(list) { return list.map((x) => bump(x)); } + +function register(fn) { return fn; } diff --git a/tests/cases/javascript/lambda-is-named-by-its-place/new-in-one-line-function.txt b/tests/cases/javascript/lambda-is-named-by-its-place/new-in-one-line-function.txt new file mode 100644 index 00000000..bee5c5ac --- /dev/null +++ b/tests/cases/javascript/lambda-is-named-by-its-place/new-in-one-line-function.txt @@ -0,0 +1,18 @@ +export class Orders { + constructor() { this.items = []; } + + totals(xs) { + const keep = xs.filter((x) => x > 0); + const twice = keep.map(function (y) { return y * 2; }); + return twice; + } + + names() { return this.items.map((i) => i.name); } +} + +export function bump(x) { return x + 1; } +register((xs) => xs.length); + +export function wire(list) { return list.map((x) => bump(x) + 1); } + +function register(fn) { return fn; } diff --git a/tests/cases/javascript/lambda-is-named-by-its-place/new-named.txt b/tests/cases/javascript/lambda-is-named-by-its-place/new-named.txt new file mode 100644 index 00000000..07de6ebe --- /dev/null +++ b/tests/cases/javascript/lambda-is-named-by-its-place/new-named.txt @@ -0,0 +1,18 @@ +export class Orders { + constructor() { this.items = []; } + + totals(xs) { + const keep = xs.filter((x) => x > 0); + const twice = keep.map(function (y) { return y * 2; }); + return twice; + } + + names() { return this.items.map((i) => i.name); } +} + +export function bump(x) { return x + 2; } +register((xs) => xs.length); + +export function wire(list) { return list.map((x) => bump(x)); } + +function register(fn) { return fn; } diff --git a/tests/cases/javascript/lambda-is-named-by-its-place/new-top.txt b/tests/cases/javascript/lambda-is-named-by-its-place/new-top.txt new file mode 100644 index 00000000..abddd69c --- /dev/null +++ b/tests/cases/javascript/lambda-is-named-by-its-place/new-top.txt @@ -0,0 +1,18 @@ +export class Orders { + constructor() { this.items = []; } + + totals(xs) { + const keep = xs.filter((x) => x > 0); + const twice = keep.map(function (y) { return y * 2; }); + return twice; + } + + names() { return this.items.map((i) => i.name); } +} + +export function bump(x) { return x + 1; } +register((xs) => xs.length + 0); + +export function wire(list) { return list.map((x) => bump(x)); } + +function register(fn) { return fn; } diff --git a/tests/cases/javascript/lambda-is-named-by-its-place/old.txt b/tests/cases/javascript/lambda-is-named-by-its-place/old.txt new file mode 100644 index 00000000..8dee6e60 --- /dev/null +++ b/tests/cases/javascript/lambda-is-named-by-its-place/old.txt @@ -0,0 +1,18 @@ +export class Orders { + constructor() { this.items = []; } + + totals(xs) { + const keep = xs.filter((x) => x > 0); + const twice = keep.map(function (y) { return y * 2; }); + return twice; + } + + names() { return this.items.map((i) => i.name); } +} + +export function bump(x) { return x + 1; } +register((xs) => xs.length); + +export function wire(list) { return list.map((x) => bump(x)); } + +function register(fn) { return fn; } diff --git a/tests/cases/javascript/lambda-is-named-by-its-place/src/lib.js b/tests/cases/javascript/lambda-is-named-by-its-place/src/lib.js new file mode 100644 index 00000000..8dee6e60 --- /dev/null +++ b/tests/cases/javascript/lambda-is-named-by-its-place/src/lib.js @@ -0,0 +1,18 @@ +export class Orders { + constructor() { this.items = []; } + + totals(xs) { + const keep = xs.filter((x) => x > 0); + const twice = keep.map(function (y) { return y * 2; }); + return twice; + } + + names() { return this.items.map((i) => i.name); } +} + +export function bump(x) { return x + 1; } +register((xs) => xs.length); + +export function wire(list) { return list.map((x) => bump(x)); } + +function register(fn) { return fn; } diff --git a/tests/cases/javascript/lambda-is-named-by-its-place/src/other.js b/tests/cases/javascript/lambda-is-named-by-its-place/src/other.js new file mode 100644 index 00000000..036378b7 --- /dev/null +++ b/tests/cases/javascript/lambda-is-named-by-its-place/src/other.js @@ -0,0 +1,3 @@ +import { bump } from './lib.js'; + +export function wireOther(list) { return list.map((x) => bump(x)); } diff --git a/tests/cases/javascript/lambda-is-named-by-its-place/src/test/lib.test.js b/tests/cases/javascript/lambda-is-named-by-its-place/src/test/lib.test.js new file mode 100644 index 00000000..8e4be167 --- /dev/null +++ b/tests/cases/javascript/lambda-is-named-by-its-place/src/test/lib.test.js @@ -0,0 +1,5 @@ +import test from 'node:test'; +import { Orders, wire } from '../lib.js'; + +test('totals', () => { new Orders().totals([1, -1]); }); +test('wire', () => { wire([1]); }); diff --git a/tests/cases/javascript/lambda-is-named-by-its-place/src/test/other.test.js b/tests/cases/javascript/lambda-is-named-by-its-place/src/test/other.test.js new file mode 100644 index 00000000..fec434b5 --- /dev/null +++ b/tests/cases/javascript/lambda-is-named-by-its-place/src/test/other.test.js @@ -0,0 +1,4 @@ +import test from 'node:test'; +import { wireOther } from '../other.js'; + +test('wireOther', () => { wireOther([1]); }); diff --git a/tests/fastpath.py b/tests/fastpath.py index e0aa3b82..3da1f87b 100644 --- a/tests/fastpath.py +++ b/tests/fastpath.py @@ -76,10 +76,13 @@ def along_rows(d): return ({x['display'] for x in d.get('direct', []) if x.get('certainty') == ALONG} | {x['display'] for x in d.get(ALONG, [])}) +# A ROW IS A DECLARATION, NOT A NAME: `direct` is compared with where each row is. Every arrow of a file is ``, +# every module body of a basename `app.`; compared as a set of names, one row located at the first arrow in the +# table matched the rules' two rows at their own lines (the hook said "called by " at a test the caller is not in) def rels(d): d = d or {} return dict(contract=sorted({x['display'] for x in d.get('contract', [])}), - direct=sorted({x['display'] for x in graph_sql.hook_direct(d)}), + direct=sorted({(x['display'], x.get('at') or '') for x in graph_sql.hook_direct(d)}), reached=len(d.get('reached', [])), tests=len(d.get('tests', []))) # THE HOOK ON ONE EDIT, BOTH PATHS. The comparison above is of the dicts; this is of what the hook PRINTS, because @@ -176,7 +179,7 @@ def main(argv=None): f"rule for: fast={sorted(fa)} rules={sorted(ra)}"); bad += 1; continue a, b = rels(fast), rels(j) # and neither path may list one of the rules' alongside rows as a dependent in a hook - listed = {p: sorted(ra & set(r['direct'])) for p, r in (('fast', a), ('rules', b))} + listed = {p: sorted(ra & {x for x, _ in r['direct']}) for p, r in (('fast', a), ('rules', b))} if any(listed.values()): print(f"FAIL {target!r}: the rules' `alongside` rows are listed as direct uses: {listed}"); bad += 1; continue if a != b: