Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {'<lambda>'}
# the names a front end gives every unnamed function it declares: `<lambda>` (Python and C#), `<anonymous-method>` (C#
# `delegate (…) { … }`), `<arrow>` and `<function-expression>` (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 <arrow>"
LAMBDA_NAMES = {'<lambda>', '<anonymous-method>', '<arrow>', '<function-expression>'}
# 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 `<arrow>` / `<function-expression>` where Python and C# say `<lambda>`
Expand Down
72 changes: 49 additions & 23 deletions plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<arrow>`,
# `<lambda>`), and so does every module body of one basename (`app.<module>`): 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 <arrow> 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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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))
Expand Down
38 changes: 38 additions & 0 deletions tests/cases/javascript/lambda-is-named-by-its-place/case.json
Original file line number Diff line number Diff line change
@@ -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, <arrow>, 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": ["<arrow", "removed", " signature "]},
{"why": "the same for an unnamed function expression",
"run": ["changed", "{repo}", "--old", "{repo}/old.txt", "--new", "{repo}/new-in-function-expression.txt", "--file", "src/lib.js"],
"want": ["body Orders.totals"],
"avoid": ["<function-expression", "removed", " signature "]},
{"why": "an arrow on the one line of the function that holds it is that function's body",
"run": ["changed", "{repo}", "--old", "{repo}/old.txt", "--new", "{repo}/new-in-one-line-function.txt", "--file", "src/lib.js"],
"want": ["body wire"],
"avoid": ["<arrow", "removed"]},
{"why": "and what it reaches are that function's tests, not those of the same-shaped arrow in another file",
"run": ["changed", "{repo}", "--old", "{repo}/old.txt", "--new", "{repo}/new-in-one-line-function.txt", "--file", "src/lib.js", "--impact"],
"want": ["src/test/lib.test.js:5"],
"avoid": ["other.test.js", "wireOther"]},
{"why": "an arrow no function encloses (handed over at the top level) is its own body change, named by where it is and targeted by file:line",
"run": ["changed", "{repo}", "--old", "{repo}/old.txt", "--new", "{repo}/new-top.txt", "--file", "src/lib.js"],
"want": ["body lib.<arrow@L14>", "impact src/lib.js:14"],
"avoid": ["removed", "impact <arrow>"]},
{"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", "<arrow", "removed"]},
{"why": "file:line on an arrow names that one arrow, by its enclosing method and line",
"run": ["impact", "src/lib.js:5"],
"want": ["change: Orders.totals.<arrow@L5>"],
"avoid": ["change: <arrow>", "<function-expression@L6>"]},
{"why": "and a function expression the same way",
"run": ["impact", "src/lib.js:6"],
"want": ["change: Orders.totals.<function-expression@L6>"],
"avoid": ["change: <function-expression>", "<arrow@L5>"]},
{"why": "the name printed for an arrow is a target that answers for that arrow alone",
"run": ["impact", "Orders.totals.<arrow@L5>"],
"want": ["change: Orders.totals.<arrow@L5>"],
"avoid": ["declarations", "change: <arrow>"]}]}
Original file line number Diff line number Diff line change
@@ -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; }
Original file line number Diff line number Diff line change
@@ -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; }
Original file line number Diff line number Diff line change
@@ -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; }
Original file line number Diff line number Diff line change
@@ -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; }
18 changes: 18 additions & 0 deletions tests/cases/javascript/lambda-is-named-by-its-place/new-top.txt
Original file line number Diff line number Diff line change
@@ -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; }
18 changes: 18 additions & 0 deletions tests/cases/javascript/lambda-is-named-by-its-place/old.txt
Original file line number Diff line number Diff line change
@@ -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; }
18 changes: 18 additions & 0 deletions tests/cases/javascript/lambda-is-named-by-its-place/src/lib.js
Original file line number Diff line number Diff line change
@@ -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; }
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { bump } from './lib.js';

export function wireOther(list) { return list.map((x) => bump(x)); }
Original file line number Diff line number Diff line change
@@ -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]); });
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import test from 'node:test';
import { wireOther } from '../other.js';

test('wireOther', () => { wireOther([1]); });
7 changes: 5 additions & 2 deletions tests/fastpath.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<arrow>`,
# every module body of a basename `app.<module>`; 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 <arrow>" 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
Expand Down Expand Up @@ -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:
Expand Down
Loading