From a0ac09ee0b27fbd3274f59b1f6dd7b7e0e9712f7 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:30:29 -0700 Subject: [PATCH 1/3] context: a type entry point starts the flow at its own methods, and an empty flow lists what was found A question naming classes got entry points that were all types; a type is not a call, so the flow had no root and, with the code shown, the answer printed only its terms and a count of files it did not list. Type entry points now start the flow at their own methods (after the callable ones), a root with a body comes before a signature unless the task names it, and a flow that is still empty says why and prints the entry points and files. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../axiomcode/scripts/axiomcode-context | 47 ++++++++++++- tests/cases/typescript/explain-flow/case.json | 35 ++++++++++ .../typescript/explain-flow/src/ledger.ts | 67 +++++++++++++++++++ 3 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 tests/cases/typescript/explain-flow/src/ledger.ts diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context index 7052d34c..66e92ca2 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context @@ -842,6 +842,32 @@ def flow(g, roots, depth_cap=FLOW_DEPTH, max_steps=FLOW_STEPS): FLOW_SOURCE_STEPS, FLOW_SOURCE_LINES, FLOW_SOURCE_CHARS = 16, 8, 3800 # code for the earliest steps until the budget, then names only +def type_members(g, types, scored): + """the methods declared inside each of `types` (symbols with no method of their own), in the order the types come: + within one type, the member matching the task best first, then one that calls something (a flow to show), then + the first declared. A member is a method whose span lies inside the type's span in the type's file, which reads the + same in every language.""" + methods, kinds = collections.defaultdict(list), collections.defaultdict(list) + for x, sy in g.sym.items(): + if not sy.get('line') or sy.get('is_test'): continue + if sy.get('method_id'): + if not is_synthetic(sy.get('name')): methods[sy.get('file') or ''].append(x) + elif sy.get('type_id'): + kinds[sy.get('file') or ''].append((sy['line'], sy.get('end_line') or sy['line'])) + out = [] + for t in types: + ty = g.sym.get(t) or {} + if ty.get('method_id') or not ty.get('type_id') or not ty.get('line'): continue + f, a, b = ty.get('file') or '', ty['line'], ty.get('end_line') or ty['line'] + # a member of a type nested inside this one belongs to that type, not to this one + nested = [(c, d) for c, d in kinds.get(f, ()) if a < c and d <= b and (c, d) != (a, b)] + inner = [x for x in methods.get(f, ()) if a <= g.sym[x]['line'] <= b + and not any(c <= g.sym[x]['line'] <= d for c, d in nested)] + inner.sort(key=lambda x: (bodiless(g, x), -(scored.get(x, (0, []))[0]), x not in g.callers_ids, g.sym[x]['line'])) + out += [x for x in inner[:1] if x not in out] + return out + + def print_flow(g, roots, steps, gaps, chosen, show_source=False, marks=None): if not steps: return False print("\nhow it runs — the call flow from " + ("where you started it" if chosen else "this task's entry points") @@ -1102,14 +1128,31 @@ def main(argv): file=sys.stderr) if not roots_: # no --from: the entry points this verb already chose, the callable ones, first FLOW_ROOTS of them - roots_ = [s_ for s_, _t, _sc in seeds if (g.sym.get(s_) or {}).get('method_id')][:FLOW_ROOTS] + # A TYPE is not a call: a question naming classes ("how is FooRepository chosen, how does CachedFoo cache") + # got entry points that were all types, the flow had no root, and the answer came back with nothing in it. + # So a type entry point starts the flow at its own methods, after the callable entry points; and a root + # with a body comes before a signature, which is a flow of one step that goes nowhere — unless the task + # NAMES the signature, which makes it the subject wherever it is declared. + cands = [s_ for s_, _t, _sc in seeds if (g.sym.get(s_) or {}).get('method_id')] + cands += [x for x in type_members(g, [s_ for s_, _t, _sc in seeds], scored) if x not in cands] + asked = {s_ for s_, _why in named} + roots_ = sorted(cands, key=lambda x: x not in asked and bodiless(g, x))[:FLOW_ROOTS] roots, steps, gaps, marks = flow(g, roots_, max_steps=FLOW_SOURCE_STEPS if show_source else FLOW_STEPS) RESULT['flow'] = [{'step': k + 1, 'depth': d, 'name': g.disp(x), 'at': g.loc(x), 'called_at_line': (via[0] if via else None), 'certainty': (ax_edges.direct_cert(via[1]) if via else 'entry'), 'repeat_of': again, 'unresolved': [f"{nm} L{l}" for l, nm in gaps.get(x, ())[:4]], 'leaves_graph': (marks.get(x, []) if again is None else [])} for k, (d, x, via, again) in enumerate(steps)] - print_flow(g, roots, steps, gaps, bool(starts), show_source, marks) + if not print_flow(g, roots, steps, gaps, bool(starts), show_source, marks): + # the entry points and files were held back because the flow was to BE the answer; with no flow they are + # the answer again, so they are printed, and the reader is told why there is no flow + print("\nno call flow: " + ("none of the entry points is a callable, or a type declaring one" + if not roots_ else "the entry points call nothing the graph resolved") + + " — here are the entry points and the files around them instead; `--from ` starts a flow") + print(f"\nentry points ({len(seeds)}):") + for sid, term, sc in seeds: + print(f" {g.disp(sid):46.46} {g.loc(sid):34.34} {term or 'best overall match'}") + budget = max(budget, 5) depth = neighbourhood(g, [s for s, _t, _sc in seeds]) # A file can match the task by NAME and be reachable by no call at all — an enum of flags, a constants diff --git a/tests/cases/typescript/explain-flow/case.json b/tests/cases/typescript/explain-flow/case.json index acabc5b8..3964c5bd 100644 --- a/tests/cases/typescript/explain-flow/case.json +++ b/tests/cases/typescript/explain-flow/case.json @@ -108,6 +108,41 @@ "page 1 of", "helper20" ] + }, + { + "why": "a question of three clauses naming classes gets entry points that are types and one interface signature; a type starts the flow at its own methods, and a root with a body comes before a signature, so the answer carries code instead of a one-step flow that goes nowhere", + "run": [ + "context", + "how is the ledger Store chosen and how does CachedStore cache entries; where does the audit journal get written", + "--source" + ], + "want": [ + "how it runs —", + " 1 CachedStore.load ", + "| const hit = this.hot.get(key);", + "AuditJournalRelay.relay" + ], + "avoid": [ + " 1 AuditJournal.append " + ] + }, + { + "why": "when every entry point is a type with no method there is no flow; the answer says so and lists the entry points and files it counted, instead of a count of files it does not show", + "run": [ + "context", + "how is the QuotaShape chosen and how does QuotaBudget hold the spent amount; where does the window get read", + "--source" + ], + "want": [ + "no call flow:", + "entry points (2):", + "QuotaShape ", + "where the work is (1 file(s)", + "ledger.ts" + ], + "avoid": [ + "lie within 3 hops of the entry points; `--budget N` lists them" + ] } ] } \ No newline at end of file diff --git a/tests/cases/typescript/explain-flow/src/ledger.ts b/tests/cases/typescript/explain-flow/src/ledger.ts new file mode 100644 index 00000000..41c5b22a --- /dev/null +++ b/tests/cases/typescript/explain-flow/src/ledger.ts @@ -0,0 +1,67 @@ +// A question that names only TYPES: "how is the ledger Store chosen, how does CachedStore keep entries, where does +// the Journal get written". No entry point is a callable, so the flow has to start at the types' own methods. + +export interface Store { + load(key: string): Promise; + keep(key: string, value: string): Promise; +} + +export class MemoryStore implements Store { + private readonly rows = new Map(); + async load(key: string): Promise { + return this.rows.get(key); + } + async keep(key: string, value: string): Promise { + this.rows.set(key, value); + } +} + +export interface AuditJournal { + append(line: string): void; +} + +export class InMemoryAuditJournal implements AuditJournal { + private readonly lines: string[] = []; + append(line: string): void { + this.lines.push(line); + } +} + +export class AuditJournalRelay { + constructor(private readonly journal: AuditJournal) {} + relay(lines: string[]): void { + for (const line of lines) this.journal.append(line); + } +} + +export class CachedStore implements Store { + private readonly hot = new Map(); + constructor(private readonly inner: Store, private readonly journal: AuditJournal) {} + async load(key: string): Promise { + const hit = this.hot.get(key); + if (hit !== undefined) return hit; + const value = await this.inner.load(key); + if (value !== undefined) this.remember(key, value); + return value; + } + async keep(key: string, value: string): Promise { + this.remember(key, value); + this.journal.append(`keep ${key}`); + await this.inner.keep(key, value); + } + private remember(key: string, value: string): void { + this.hot.set(key, value); + } +} + +// Types with no method at all: a question naming only these has no flow anywhere, and the answer must say so and +// still list what it found. +export interface QuotaShape { + readonly limit: number; + readonly window: number; +} + +export interface QuotaBudget { + readonly shape: QuotaShape; + readonly spent: number; +} From dd61cf7ac1fbf1d81a9f43cebb8769cb9ca65e6c Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:51:27 -0700 Subject: [PATCH 2/3] impact, hooks: a caller is its declaration, not its display name Every unnamed function carries one display (, , , ) and every module body of a basename another. Keyed by that name, file:line on an arrow answered for every arrow, an edit inside an arrow came back as a removed , and the hook's fast path merged all same-named callers into one row located at whichever came first. - the unnamed-function names of every front end are never a target on their own - the fast path keys callers by id and locates each at its lowest call site, as the rules do - the parity check compares direct rows with their locations - a JavaScript case for arrows and function expressions, with a named-function control Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../skills/axiomcode/scripts/axiomcode-path | 8 ++- .../skills/axiomcode/scripts/graph_sql.py | 72 +++++++++++++------ .../lambda-is-named-by-its-place/case.json | 38 ++++++++++ .../new-in-arrow.txt | 18 +++++ .../new-in-function-expression.txt | 18 +++++ .../new-in-one-line-function.txt | 18 +++++ .../new-named.txt | 18 +++++ .../lambda-is-named-by-its-place/new-top.txt | 18 +++++ .../lambda-is-named-by-its-place/old.txt | 18 +++++ .../lambda-is-named-by-its-place/src/lib.js | 18 +++++ .../lambda-is-named-by-its-place/src/other.js | 3 + .../src/test/lib.test.js | 5 ++ .../src/test/other.test.js | 4 ++ tests/fastpath.py | 7 +- 14 files changed, 235 insertions(+), 28 deletions(-) create mode 100644 tests/cases/javascript/lambda-is-named-by-its-place/case.json create mode 100644 tests/cases/javascript/lambda-is-named-by-its-place/new-in-arrow.txt create mode 100644 tests/cases/javascript/lambda-is-named-by-its-place/new-in-function-expression.txt create mode 100644 tests/cases/javascript/lambda-is-named-by-its-place/new-in-one-line-function.txt create mode 100644 tests/cases/javascript/lambda-is-named-by-its-place/new-named.txt create mode 100644 tests/cases/javascript/lambda-is-named-by-its-place/new-top.txt create mode 100644 tests/cases/javascript/lambda-is-named-by-its-place/old.txt create mode 100644 tests/cases/javascript/lambda-is-named-by-its-place/src/lib.js create mode 100644 tests/cases/javascript/lambda-is-named-by-its-place/src/other.js create mode 100644 tests/cases/javascript/lambda-is-named-by-its-place/src/test/lib.test.js create mode 100644 tests/cases/javascript/lambda-is-named-by-its-place/src/test/other.test.js 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: From f6f9249648253291b57cec3c009ef4e8d1545fec Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:06:23 -0700 Subject: [PATCH 3/3] evidence for the rows an answer is not sure of Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- plugins/axiomcode/hooks/changes.py | 8 +- plugins/axiomcode/mcp/server.py | 49 +- .../skills/axiomcode/scripts/ax_evidence.py | 582 ++++++++++++++++++ .../skills/axiomcode/scripts/ax_grep.py | 41 +- .../skills/axiomcode/scripts/axiomcode | 10 + .../axiomcode/scripts/axiomcode-context | 7 + .../skills/axiomcode/scripts/axiomcode-impact | 47 +- .../skills/axiomcode/scripts/axiomcode-path | 10 +- .../axiomcode/scripts/axiomcode-test-impact | 10 +- .../evidence-for-uncertain-rows/case.json | 88 +++ .../evidence-for-uncertain-rows/cs/Stock.cs | 19 + .../java/app/Cache.java | 20 + .../java/app/Pipeline.java | 10 + .../java/app/Store.java | 7 + .../evidence-for-uncertain-rows/js/client.js | 18 + .../evidence-for-uncertain-rows/js/store.js | 5 + .../evidence-for-uncertain-rows/py/billing.py | 33 + .../evidence-for-uncertain-rows/py/gateway.py | 8 + .../evidence-for-uncertain-rows/ts/bus.ts | 21 + .../evidence-for-uncertain-rows/ts/callers.ts | 26 + .../evidence-for-uncertain-rows/ts/ledger.ts | 5 + .../evidence-for-uncertain-rows/ts/orders.ts | 5 + tests/run.py | 12 +- 23 files changed, 1012 insertions(+), 29 deletions(-) create mode 100644 plugins/axiomcode/skills/axiomcode/scripts/ax_evidence.py create mode 100644 tests/cases/typescript/evidence-for-uncertain-rows/case.json create mode 100644 tests/cases/typescript/evidence-for-uncertain-rows/cs/Stock.cs create mode 100644 tests/cases/typescript/evidence-for-uncertain-rows/java/app/Cache.java create mode 100644 tests/cases/typescript/evidence-for-uncertain-rows/java/app/Pipeline.java create mode 100644 tests/cases/typescript/evidence-for-uncertain-rows/java/app/Store.java create mode 100644 tests/cases/typescript/evidence-for-uncertain-rows/js/client.js create mode 100644 tests/cases/typescript/evidence-for-uncertain-rows/js/store.js create mode 100644 tests/cases/typescript/evidence-for-uncertain-rows/py/billing.py create mode 100644 tests/cases/typescript/evidence-for-uncertain-rows/py/gateway.py create mode 100644 tests/cases/typescript/evidence-for-uncertain-rows/ts/bus.ts create mode 100644 tests/cases/typescript/evidence-for-uncertain-rows/ts/callers.ts create mode 100644 tests/cases/typescript/evidence-for-uncertain-rows/ts/ledger.ts create mode 100644 tests/cases/typescript/evidence-for-uncertain-rows/ts/orders.ts diff --git a/plugins/axiomcode/hooks/changes.py b/plugins/axiomcode/hooks/changes.py index 44fba37d..a547f630 100644 --- a/plugins/axiomcode/hooks/changes.py +++ b/plugins/axiomcode/hooks/changes.py @@ -15,7 +15,7 @@ those, the tests) — ≤ 3 declarations per event, in parallel, a few lines each.""" import concurrent.futures, json, os, re, subprocess, sys, tempfile sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'skills', 'axiomcode', 'scripts')) -import graph_sql +import graph_sql, ax_evidence sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import _host, _graphline, _where @@ -114,6 +114,12 @@ def impact(d): # count nobody can check. This cost a whole re-derivation once: three declarations reported 0 reached and # 0 tests where the rules report ~1800 and ~1470, and there was no way to tell from the block whether that # was the fast path answering, the rules answering, or the CLI having given up. + # with evidence on (AXIOMCODE_EVIDENCE, ax_evidence.py), the line that decides the strongest uncertain readers + if ax_evidence.on() and reads: + if not any(x.get('evidence') for x in reads): ax_evidence.impact_doc({'targets': j.get('targets') or [{'label': d['symbol']}], 'direct': reads}, cwd) + for x in [x for x in reads if (x.get('evidence') or {}).get('decider')][:2]: + dd = x['evidence']['decider'] + lines.append(f" decided: [{x['certainty']}] {x['at'].split('/')[-1]} ← {dd['at'].split('/')[-1]}: {dd['text'][:100]} [{dd['kind']}]") lines.append(f" [{'fast path' if j.get('_sql') else 'rules'}] reaches {len(rc)} more callable(s) through resolved calls within 12 hops; {len(ts)} test(s) reach the change" + (": " + ', '.join(f"{t['owner'] or (t.get('at') or '').rsplit('/', 1)[-1].split(':')[0] or 'test'}::{t['name']}" for t in ts[:3]) + (' …' if len(ts) > 3 else '') if ts else '') + (f"; {j['unresolved_inside']} unresolved call(s) inside — a lower bound" if j.get('unresolved_inside') else '')) if len(decls) > 3: lines.append(f" … +{len(decls) - 3} more: axiomcode changed --impact") if bodies: diff --git a/plugins/axiomcode/mcp/server.py b/plugins/axiomcode/mcp/server.py index 4bfb91aa..8a91153d 100755 --- a/plugins/axiomcode/mcp/server.py +++ b/plugins/axiomcode/mcp/server.py @@ -122,10 +122,12 @@ def scripts_module(name): PARAM = {'--in': 'in_path', '--tests-only': 'tests', '--tests': 'tests', '--tests-in': 'tests_in', '--from': 'from_', '--why': 'why', '--source': 'source', '--explain': 'explain', '--every': 'every', '--staged': 'staged', '--impact': 'impact', '--delete': 'delete', '--depth': 'depth', '--limit': 'limit', '--page': 'page', - '--budget': 'budget', '--kind': 'kind', '--range': 'range', '--fresh': 'fresh', '--no-refresh': 'refresh'} + '--budget': 'budget', '--kind': 'kind', '--range': 'range', '--fresh': 'fresh', '--no-refresh': 'refresh', + '--drop': 'drop', '--exact': 'exact', '--alongside': 'alongside'} # a CLI switch that turns a parameter OFF: `--no-refresh` is refresh=False here NEGATED = {'--no-refresh'} -SWITCH = {'--tests-only', '--tests', '--why', '--source', '--explain', '--every', '--staged', '--impact', '--delete', '--fresh'} +SWITCH = {'--tests-only', '--tests', '--why', '--source', '--explain', '--every', '--staged', '--impact', '--delete', '--fresh', + '--exact', '--alongside'} PARAMS = {} # tool name -> its parameter names, filled as the tools are declared # a flag, and its value when what follows looks like one (, 'x', N, 2, a.b, src/x) rather than prose ("no --in was given") _FLAG = re.compile(r"(? str: """Build (or refresh) the call graph of a repository: parser → engine → /.axiomcode/out/graph.sqlite. Run once before path/impact/graph. lang: java|typescript|python|javascript|csharp when the repo mixes languages; src: subtree to analyse (e.g. src); library: comma-separated dependency roots so calls into them resolve.""" @@ -288,43 +308,48 @@ def axiomcode_index(repo: str = ".", lang: str = '', src: str = '', library: str return run(a) @srv.tool() -def axiomcode_context(task: str, repo: str = ".", in_path: str = '', budget: int = 0, source: bool = False, page: Page = 1, explain: bool = False, from_: str = '', fresh: bool = False, full: bool = False, limit: int = 0, refresh: bool = True) -> str: +@_doc +def axiomcode_context(task: str, repo: str = ".", in_path: str = '', budget: int = 0, source: bool = False, page: Page = 1, explain: bool = False, from_: str = '', fresh: bool = False, full: bool = False, limit: int = 0, refresh: bool = True, evidence: str = '', drop: list[str] = [], exact: bool = False, alongside: bool = False) -> str: """[resolved]/[sound] rows are verified against the graph; the answer ends with `next:`, the one step to take. START HERE when you have a task in words and no name to ask about yet. A task that asks HOW something works ("how does X …", "explain …", or explain=True) also gets the call FLOW — every step in the order the calls are written, with ⚠ where the graph lost a call; from_ (comma-separated names) starts the flow where you choose. Pass source=True with it: each step then carries its code, so answer from that and open a file only for a step whose body was cut or a ⚠ call. Otherwise it returns the files and callables that task touches, from the problem statement alone. Deterministic — task terms scored against the graph's vocabulary by inverse document frequency, tests demoted, the closure walked from the best seed per term and ranked by nearest hop. in_path accepts SEVERAL paths, comma-separated: they are combined rather than intersected, so a change spanning two roots comes back in one call. budget is how many files are listed (default 12; the ranking is the same at any budget); source=True includes the code. A long answer comes in pages; ask for page=2 only if page 1's files are not enough. Ends by saying what it could not see. Without source/explain/from_ the answer is one site per line (`path:line: code [tag]`), capped with a count of the rest; limit=N lists more, full=True gives the prose. After an edit the answer comes at once from the last graph, rows in edited files marked (may be out of date); fresh=True waits for the rebuild. refresh=False: read-only, answers from the graph as it is and never starts a rebuild (an answer that did start one says so on its first line).""" need_repo(repo) flow = source or explain or from_.strip() or _paged(page) or budget a = ['context', task, repo] + grep(full or flow, limit) + (['--fresh'] if fresh else []) + (['--in', in_path] if in_path else []) + (['--budget', str(budget)] if budget else []) + (['--source'] if source else []) + _pg(page) + (['--explain'] if explain else []) + [x for n in from_.split(',') if n.strip() for x in ('--from', n.strip())] - return run(a + NOREF(refresh)) + return run(a + ev(evidence, drop, exact, alongside) + NOREF(refresh)) @srv.tool() -def axiomcode_path(from_: str, to: str, repo: str = ".", every: bool = False, in_path: str = '', depth: int = 0, limit: int = 0, page: Page = 1, fresh: bool = False, full: bool = False, why: bool = False, refresh: bool = True) -> str: +@_doc +def axiomcode_path(from_: str, to: str, repo: str = ".", every: bool = False, in_path: str = '', depth: int = 0, limit: int = 0, page: Page = 1, fresh: bool = False, full: bool = False, why: bool = False, refresh: bool = True, evidence: str = '', drop: list[str] = [], exact: bool = False, alongside: bool = False) -> str: """[resolved]/[sound] rows are verified against the graph, so a change need not re-derive them by reading (to explain how something works, read each hop's body); the answer ends with `next:`, the one step to take. A chain of calls from A to B in the graph, each hop verified, or why there is none. When you have ONE concept word you can name, a bare fragment resolves to every declaration containing it, so path('decrypt', '*') answers "what is the decryption code and what does it touch". For a whole task in words, with no name at all, use axiomcode_context first. Endpoints otherwise as written in the code: Owner.method, method, Type, Outer$Inner.m, file.java:123, file.py, @Decoration, a library call as written (new File, Files.readAllBytes). '*' on one side = everything that reaches B / everything A reaches. every=True lists every route; in_path restricts to files containing it; depth bounds a closure. A long answer comes in pages, nearest routes first, with the whole answer's counts on every page; ask for page=2 only if page 1 is not enough. fresh=True: after an edit, wait for the rebuild instead of answering from the last graph with rows in edited files marked (may be out of date). The answer is one site per line, each hop at the line its call is written on (`path:line: code [resolved · hop 1/3 → B]`); full=True gives the prose, which also says why when there is no chain. why=True adds, after the endpoint line, how each endpoint name was resolved: the lookup step that matched it (exact declaration, qualified suffix, simple name, a type used by name, ...), the declarations it weighed with file:line, and why that one won or why the name matched nothing (it gives the prose). refresh=False: read-only, answers from the graph as it is and never starts a rebuild (an answer that did start one says so on its first line).""" need_repo(repo) paged = _paged(page); full = full or why a = ['path', from_, to, repo] + grep(full or paged, limit) + (['--why'] if why else []) + (['--fresh'] if fresh else []) + (['--every'] if every else []) + (['--in', in_path] if in_path else []) + (['--depth', str(depth)] if depth else []) + (['--limit', str(limit)] if limit and (full or paged) else []) + (['--page', str(page)] if paged else []) - return run(a + NOREF(refresh)) + return run(a + ev(evidence, drop, exact, alongside) + NOREF(refresh)) @srv.tool() -def axiomcode_impact(targets: list[str], repo: str = ".", tests: bool = False, why: bool = False, tests_in: str = '', depth: int = 0, in_path: str = '', kind: str = '', page: Page = 1, budget: int = 0, limit: int = 0, delete: bool = False, fresh: bool = False, full: bool = False, refresh: bool = True) -> str: +@_doc +def axiomcode_impact(targets: list[str], repo: str = ".", tests: bool = False, why: bool = False, tests_in: str = '', depth: int = 0, in_path: str = '', kind: str = '', page: Page = 1, budget: int = 0, limit: int = 0, delete: bool = False, fresh: bool = False, full: bool = False, refresh: bool = True, evidence: str = '', drop: list[str] = [], exact: bool = False, alongside: bool = False) -> str: """Trust it: [resolved]/[sound] rows are verified against the graph, so do not re-derive them by reading; the answer ends with `next:`, the one step to take. What has to be looked at again when a declaration changes: must-change-with-it (overrides, subtypes), everything that directly uses it (with how sure each is), everything that reaches those, and the bound (unresolved calls). The tests are always counted, by rung, with the strong-route ones named and the top test files. Ask for the full list SECOND, only if you need it: tests=True returns ONLY the tests, grouped by rung and test file (the CLI's --tests-only); why=True adds each test's route, and after each `change:` line how its target name was resolved (the lookup step that matched, the declarations weighed with file:line, why that one won or why nothing matched); tests_in narrows that listing to test files containing it. Long answers come in pages of ~2000 tokens: every page carries the counts of the WHOLE answer and the rows come strongest first, so page 1 is usually enough; page=2 continues with the rows page 1 did not print (a one-page answer says there is no page 2), page="all" gives every row. budget changes the page size. Targets as written: Owner.method, Owner.field, Type, Owner.method(param), Type, Owner.method:local, or file.ts:123 (the declaration at that line). When you know where the declaration is, target it by file:line: a bare name answers for EVERY declaration of that name, and two unrelated functions in different files come back as one answer. kind: method|field|type|param|typeparam|var when a name is declared as several kinds. limit: rows shown per section (the `… +N (limit=N)` lines); delete=True adds a verdict on whether it is safe to delete. fresh=True: after an edit, wait for the rebuild (use it before a delete or a rename) instead of answering from the last graph with rows in edited files marked (may be out of date). The answer is one site per line, surest first (`path:line: code [resolved | one of a set | by name | text | hop N | test]`), capped with a count of the rest; full=True gives the sectioned prose (why, delete, budget and page give it too). refresh=False: read-only, answers from the graph as it is and never starts a rebuild (an answer that did start one says so on its first line).""" need_repo(repo) prose = full or why or delete or _paged(page) or budget a = ['impact', *targets, repo] + grep(prose, limit) + (['--fresh'] if fresh else []) + (['--tests-only'] if tests else []) + (['--why'] if why else []) + (['--tests-in', tests_in] if tests_in else []) + (['--depth', str(depth)] if depth else []) + (['--in', in_path] if in_path else []) + (['--kind', kind] if kind else []) + _pg(page) + (['--budget', str(budget)] if budget else []) + (['--limit', str(limit)] if limit and prose else []) + (['--delete'] if delete else []) - return run(a + NOREF(refresh)) + return run(a + ev(evidence, drop, exact, alongside) + NOREF(refresh)) @srv.tool() -def axiomcode_changed(repo: str = ".", files: list[str] = [], range: str = '', staged: bool = False, impact: bool = False, page: Page = 1, refresh: bool = True) -> str: +@_doc +def axiomcode_changed(repo: str = ".", files: list[str] = [], range: str = '', staged: bool = False, impact: bool = False, page: Page = 1, refresh: bool = True, evidence: str = '', drop: list[str] = [], exact: bool = False, alongside: bool = False) -> str: """Which declarations an edit changed and HOW — signature (parameters added / removed / retyped, return type), field (its type, name, initializer), type header, body only, removed, added (a new file is one `added` line) — the working tree against the commit the graph was built from (default), your branch's commits (range='a..b': read from `git merge-base a b`, so commits a received after you branched are not yours; a note says so when a has moved), or the index (staged=True); each with the target impact takes. When the working tree is clean but HEAD has commits of its own, it says which range=... to ask. files=[...] limits it to those files; on a copy without git (which it refuses otherwise) every declaration in a named file counts as changed. Changed files outside every indexed language (fixtures, case data, a schema) are named, never dropped. impact=True runs impact on all of them as one change set and returns its answer. refresh=False: read-only, answers from the graph as it is and never starts a rebuild (an answer that did start one says so on its first line).""" need_repo(repo) a = ['changed', repo, *files] + (['--range', range] if range else []) + (['--staged'] if staged else []) + (['--impact'] if impact else []) + _pg(page) - return run(a + NOREF(refresh)) + return run(a + ev(evidence, drop, exact, alongside) + NOREF(refresh)) @srv.tool() -def axiomcode_test_impact(repo: str = ".", files: list[str] = [], range: str = '', staged: bool = False, in_path: str = '', limit: int = 0, why: bool = False, page: Page = 1, full: bool = False, refresh: bool = True) -> str: +@_doc +def axiomcode_test_impact(repo: str = ".", files: list[str] = [], range: str = '', staged: bool = False, in_path: str = '', limit: int = 0, why: bool = False, page: Page = 1, full: bool = False, refresh: bool = True, evidence: str = '', drop: list[str] = [], exact: bool = False, alongside: bool = False) -> str: """Which tests actually have to run for the edit in front of you: the test files that reach any changed declaration, with the chain, so the selection can be checked rather than trusted, and the command that runs them. Working tree by default; range='a..b' for your branch's commits (from `git merge-base a b`, so a base branch that moved on is not counted as your change); staged=True for the index; files=[...] for named files (a named file with no edit, or any on a copy without git, counts whole: the tests of everything in it). An edited test file is itself listed to run. Changed files outside every indexed language (fixtures, case data) are named with the test files that name them in their text. Conservative by design — a test reached only through an edge the graph does not encode (reflection, a service loader, a subprocess, a runtime-built case) will NOT appear, so it is a lower bound. why=True prints the chain for each. The answer is one test per line (`path:line: code [test · resolved · hop N]`), capped with a count of the rest, and the command that runs them; limit=N lists more, full=True gives the prose. refresh=False: read-only, answers from the graph as it is and never starts a rebuild (an answer that did start one says so on its first line).""" need_repo(repo) prose = full or why or _paged(page) a = ['test-impact', repo, *files] + grep(prose, limit) + (['--range', range] if range else []) + (['--staged'] if staged else []) + (['--in', in_path] if in_path else []) + (['--limit', str(limit)] if limit and prose else []) + (['--why'] if why else []) + _pg(page) - return run(a + NOREF(refresh)) + return run(a + ev(evidence, drop, exact, alongside) + NOREF(refresh)) @srv.tool() def axiomcode_graph(repo: str = ".", out: str = '', refresh: bool = True) -> str: diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_evidence.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_evidence.py new file mode 100644 index 00000000..4a625ae2 --- /dev/null +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_evidence.py @@ -0,0 +1,582 @@ +#!/usr/bin/env python3 +"""Evidence for the rows an answer is not sure of. One module, used by every verb and every surface. + +A row that is not an exact edge -- [by name], [one of a set], [registered], [text], a dispatch or key join -- is a +lead: the graph matched a NAME, or a set, and could not say which declaration the call reaches. Printed alone, the +reader has to open the file to decide it, and measured over the loops' traces about half of the answers that named +files were followed within three tool calls by a Read or a grep of one of them (55% when the answer had [by name] +rows). What decides such a row is almost always ONE line: where the call's receiver (or its dispatch key) gets its +value -- a parameter's annotation, a field declared or assigned in the constructor, a local, an import, the constant +a registration names. So each non-exact row carries two lines: + + call the line the call is written on (file:line and its trimmed text) + decider the line that decides what the receiver is, and its kind (param, field, local, assigned, import, key …) + +and `only_through`: how many callables and tests the answer reaches ONLY through that row, with the way to ask again +without it (`--drop `, or `--exact` for exact edges alone). Evidence is given for the five strongest +non-exact rows; the rest are counted. Exact rows get none, so an answer with no uncertain row does not grow. + +The switch: AXIOMCODE_EVIDENCE=on|off (the dispatcher sets it from --evidence / --no-evidence; the MCP server from its +`evidence` parameter; the hooks inherit it). OFF by default: with it off nothing here runs and every answer is the +answer it was. AXIOMCODE_DROP (comma-separated file:line or row ids), AXIOMCODE_EXACT=1 and AXIOMCODE_ALONGSIDE=1 are +the ask-again and list-alongside switches, set from --drop, --exact and --alongside. + +The decider is read from the source text, not from the graph: the graph has already said it could not type the +receiver, so what it knows is exactly what is missing. It is a heuristic over the file's own lines, per language, and +it says what KIND of line it found; when it finds none the row carries only its call line. +""" +import collections, os, re + +TOP = 5 # rows that get evidence; the rest are counted +TEXT = 150 # a line's text in --json, trimmed (a line is bounded at 160 characters) +PRINTED = 90 # and as printed under a row +UP = 250 # lines searched above a call for its receiver's parameter or local + +# a certainty that is an exact edge (or not a row about a call at all): no evidence +EXACT = {None, '', 'resolved', 'sound', 'entry', 'defines', 'defines (not a call)', 'must change', 'alongside', + 'stubs it', 'at import', 'decorator', 'test'} + + +def _env(k): + return (os.environ.get(k) or '').strip().lower() + + +def on(): + return _env('AXIOMCODE_EVIDENCE') in ('on', '1', 'true', 'yes') + + +def drops(): + return {x.strip() for x in (os.environ.get('AXIOMCODE_DROP') or '').split(',') if x.strip()} + + +def exact_only(): + return _env('AXIOMCODE_EXACT') in ('1', 'on', 'true', 'yes') + + +def list_alongside(): + return _env('AXIOMCODE_ALONGSIDE') in ('1', 'on', 'true', 'yes') + + +def is_exact(cert): + return cert in EXACT + + +def lang_of(f): + e = os.path.splitext(f or '')[1].lower() + if e in ('.py', '.pyi'): return 'python' + if e == '.java': return 'java' + if e == '.cs': return 'csharp' + if e in ('.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.vue', '.svelte'): return 'ts' + return 'python' if not e else 'ts' + + +def trim(s): + s = (s or '').strip() + return s if len(s) <= TEXT else s[:TEXT - 1] + '…' + + +class Source: + """the lines of the repository's files, read once each""" + def __init__(self, repo): + self.repo, self.files = repo or '.', {} + + def lines(self, f): + if f not in self.files: + try: + with open(os.path.join(self.repo, f), encoding='utf-8', errors='replace') as h: self.files[f] = h.read().split('\n') + except OSError: self.files[f] = None + return self.files[f] + + +def split_at(at): + f, _, n = (at or '').rpartition(':') + return (f, int(n)) if f and n.isdigit() else (None, None) + + +def simple(name): + """the name a call writes: `Owner.get (at x.ts:3)` → get, `Owner.m(int)` → m""" + n = (name or '').split(' (at ')[0].split('(')[0].strip() + n = re.split(r'[.#:$/]', n)[-1] if n else '' + return '' if n.startswith('<') else n + + +# ── the receiver of a call ───────────────────────────────────────────────────────────────────────────────────────────── +ID = r'[A-Za-z_$][\w$]*' +RECV = re.compile(r'((?:' + ID + r'(?:\(\s*\))?\s*(?:\?\.|!\.|\.|->)\s*)*' + ID + r'(?:\((?:[^()]|\([^()]*\))*\))?)\s*(?:\?\.|!\.|\.|->)\s*$') + + +def receiver(text, name): + """the receiver expression written before `.name(` on a line, or None for a bare call / no call of the name""" + for m in re.finditer(r'(?:\?\.|!\.|\.|->)\s*' + re.escape(name) + r'\s*(?:<[^()]*>)?\s*[(`]', text): + r = RECV.search(text[:m.start()] + '.') + if r: return re.sub(r'\s+', '', r.group(1)) + return None + + +def parts(recv): + """`this.a.b()` → ('this', ['a', 'b()']); a separator inside parentheses does not split""" + ps, cur, depth, s, i = [], '', 0, recv or '', 0 + while i < len(s): + c = s[i] + if c == '(': depth += 1 + elif c == ')': depth -= 1 + if depth == 0 and (s.startswith('->', i) or c == '.' or (c in '?!' and s.startswith('.', i + 1))): + ps.append(cur); cur = '' + i += 2 if (s.startswith('->', i) or c in '?!') else 1 + continue + cur += c; i += 1 + ps.append(cur) + return ps[0], ps[1:] + + +TS_MOD = r'(?:(?:private|public|protected|readonly|static|declare|override|abstract|export|async)\s+)*' +JV_MOD = r'(?:(?:private|public|protected|internal|static|final|readonly|volatile|transient|const|required|new|override|virtual|sealed|unsafe)\s+)*' +JV_KW = {'return', 'new', 'throw', 'await', 'yield', 'else', 'case', 'in', 'is', 'as', 'out', 'ref', 'using', 'import', 'package', 'var', 'val'} + + +def _type_in(lang, line, x, annotated_only=False): + """the type a declaration line gives x, if it writes one (a parameter's only from its annotation)""" + if lang in ('ts', 'python'): + m = re.search(r'\b' + re.escape(x) + r'\s*[?!]?\s*:\s*([A-Za-z_$][\w$.]*)', line) + if m and m.group(1) not in ('function',): return m.group(1) + else: + m = re.search(r'([A-Za-z_][\w.]*)(?:<[^;=()]*>)?(?:\[\])*\??\s+' + re.escape(x) + r'\b', line) + if m and m.group(1) not in JV_KW: return m.group(1) + if annotated_only: return None + m = re.search(r'=\s*(?:new\s+)?([A-Za-z_$][\w$.]*)\s*(?:<[^()]*>)?\s*\(', line) + if m and m.group(1) not in ('async', 'function', 'await', 'lambda') and (lang != 'python' or m.group(1)[:1].isupper() or '.' in m.group(1)): + return ('new ' if 'new ' + m.group(1) in line else '') + m.group(1) + '(…)' + return None + + +CLASS = re.compile(r'^(\s*)(?:(?:export|default|abstract|public|private|protected|internal|static|final|sealed|partial|data|open|declare)\s+)*' + r'(?:class|interface|struct|record|object)\s+' + ID) + + +def _indent(s): + return len(s) - len(s.lstrip()) + + +def _class_span(L, n): + """[lo, hi) the lines of the class that encloses line n, else the whole file: a field is the enclosing class's own, + and the same name declared by another class in the file decides nothing""" + ind = _indent(L[n - 1]) + for i in range(n - 1, -1, -1): + m = CLASS.match(L[i]) + if m and len(m.group(1)) < ind: + ci = len(m.group(1)) + for j in range(i + 1, len(L)): + t = L[j].strip() + if t and _indent(L[j]) <= ci and not re.match(r'[{})\]]|//|#|/?\*|@|where\b|:', t): return i, j + return i, len(L) + return 0, len(L) + + +def _field(lang, L, x, n): + """where a field x of the class enclosing line n gets its value: its declaration, a constructor parameter property, + or an assignment""" + ex = re.escape(x) + if lang == 'ts': + pats = [(re.compile(r'(?:private|public|protected|readonly)\s+(?:readonly\s+)?' + ex + r'\s*[?!]?\s*:'), 'constructor parameter'), + (re.compile(r'^\s*' + TS_MOD + r'#?' + ex + r'\s*[?!]?\s*[:=](?!=)'), 'field'), + (re.compile(r'\bthis\.' + ex + r'\s*=(?!=)'), 'assigned')] + elif lang == 'python': + pats = [(re.compile(r'^\s+' + ex + r'\s*:\s*[A-Za-z_]'), 'field'), + (re.compile(r'\bself\.' + ex + r'\s*(?::[^=]+)?=(?!=)'), 'assigned'), + (re.compile(r'^\s+' + ex + r'\s*=(?!=)'), 'class attribute')] + else: + pats = [(re.compile(r'^\s*(?:\[[^\]]*\]\s*|@\w+(?:\([^)]*\))?\s+)*' + JV_MOD + r'[A-Za-z_][\w.<>\[\]?, ]*\s+' + ex + r'\s*(?:=(?!=)|;|\{|=>)'), 'field'), + (re.compile(r'\bthis\.' + ex + r'\s*=(?!=)'), 'assigned')] + lo, hi = _class_span(L, n) + for rx, kind in pats: + for i in range(lo, hi): + line = L[i] + if rx.search(line) and not re.match(r'\s*(//|#|\*|/\*)', line): + if kind == 'assigned': + # `self.gw = gw` / `this.loader = loader` decides nothing on its own: the constructor parameter it + # copies may say the type + rhs = re.search(r'=\s*(' + ID + r')\s*;?\s*$', line.split('#')[0].split('//')[0]) + if rhs: + p = _param(lang, L, rhs.group(1), i + 1) + if p and p[2]: return (p[0], 'constructor parameter', p[2]) + return (i + 1, kind, _type_in(lang, line, x)) + return None + + +JV_NOT = r'(?!(?:return|new|throw|else|case|await|yield|if|for|while|switch|catch|using|lock|do|try|var)\b)' +NAMED = { + 'python': re.compile(r'^\s*(?:async\s+)?def\s+\w+\s*\('), + 'ts': re.compile(r'\bfunction\b\s*\*?\s*[\w$]*\s*(?:<[^>]*>)?\s*\(|\bconstructor\s*\(|^\s*' + TS_MOD + r'(?:get\s+|set\s+)?' + ID + + r'\s*(?:<[^>]*>)?\s*\((?:.*\)\s*(?::\s*[^;{=]+)?\{\s*$|\s*$)'), + 'java': re.compile(r'^\s*' + JV_NOT + r'(?:(?:\[[^\]]*\]|@\w+(?:\([^)]*\))?)\s*)*' + JV_MOD + r'(?:[\w<>\[\],.?]+\s+)?' + ID + r'\s*\([^;]*$'), +} +ANON = re.compile(r'\(([^()]*)\)\s*(?::\s*[^=]+?)?\s*(?:=>|->)|\b(' + ID + r')\s*(?:=>|->)|\blambda\b([^:]*):') + + +def _signatures(lang, L, n): + """the signatures enclosing line n, innermost first, up to and including the nearest named one: (line index, the + signature's text over up to 8 lines, named?)""" + named = NAMED['java' if lang in ('java', 'csharp') else lang] + for i in range(n - 1, max(-1, n - 1 - UP), -1): + line = L[i] + if named.search(line): + text = ' '.join(L[i:i + 8]) + text = text[:text.find('{')] if '{' in text else text + yield i, text, True + return + a = ANON.search(line) + if a: yield i, a.group(0), False + + +def _param(lang, L, x, n, up=UP): + """the parameter x of a callable enclosing line n: of the nearest named signature, or of a closure inside it""" + ex = re.escape(x) + if lang in ('ts', 'python'): + rx = re.compile(r'(?:^|[(,]|\blambda\b)\s*(?:@[\w.]+(?:\([^)]*\))?\s*)*(?:(?:private|public|protected|readonly)\s+)*(?:\*{1,2})?' + ex + r'\s*[?]?\s*(?::|=[^=>]|,|\)|=>)') + else: + rx = re.compile(r'(?:[(,]|^)\s*(?:(?:final|this|params|ref|out|in)\s+|@\w+(?:\([^)]*\))?\s+)*[A-Za-z_][\w.<>\[\]?, ]*\s+' + ex + r'\s*(?:[,)=]|$)' + r'|\(\s*' + ex + r'\s*\)\s*(?:->|=>)|\b' + ex + r'\s*(?:->|=>)') + for i, text, named in _signatures(lang, L, n): + if rx.search(text) or (not named and re.search(r'\b' + ex + r'\b', text)): + # the line of the signature that writes it (a parameter list over several lines) + k = next((j for j in range(i, min(len(L), i + 8)) if re.search(r'\b' + ex + r'\b', L[j])), i) + return (k + 1, 'param', _type_in(lang, L[k], x, annotated_only=True)) + return None + + +def _local(lang, L, x, n, up=UP): + ex = re.escape(x) + if lang == 'ts': + rx = [re.compile(r'\b(?:const|let|var)\s+' + ex + r'\b'), re.compile(r'\b(?:const|let|var)\s*[{\[][^=]*\b' + ex + r'\b'), + re.compile(r'\bfor\s*\(\s*(?:const|let|var)\s+' + ex + r'\b')] + elif lang == 'python': + rx = [re.compile(r'^\s*' + ex + r'\s*(?::[^=]+)?=(?!=)'), re.compile(r'\bfor\s+(?:[\w, ]*\b)?' + ex + r'\b[\w, ]*\s+in\b'), + re.compile(r'\bas\s+' + ex + r'\s*[:,)]'), re.compile(r'^\s*(?:[\w.]+\s*,\s*)*' + ex + r'\s*(?:,\s*[\w.]+\s*)*=(?!=)')] + else: + rx = [re.compile(r'(?:^|[;({])\s*(?:final\s+)?[A-Za-z_][\w.<>\[\]?, ]*\s+' + ex + r'\s*=(?!=)'), + re.compile(r'\b(?:var|val)\s+' + ex + r'\b'), re.compile(r'\bforeach\s*\([^)]*\s' + ex + r'\s+in\b'), + re.compile(r'\bfor\s*\([^:;]*\s' + ex + r'\s*:')] + # within the enclosing callable (a module-level `let` above a test's callbacks is the test file's own local) + top = [i for i, _t, named in _signatures(lang, L, n) if named] + stop = top[0] if top and lang != 'ts' else max(-1, n - 1 - up) + for i in range(n - 1, stop - 1 if stop >= 0 else -1, -1): + if any(r.search(L[i]) for r in rx) and not re.match(r'\s*(//|#|\*)', L[i]): + # a member declared with a modifier is the class's field, not a local (a one-line method has no signature + # line above the call to stop the search at) + if lang in ('java', 'csharp') and re.match(r'\s*(?:private|public|protected|internal|static|readonly|const)\b', L[i]): return None + return (i + 1, 'local', _type_in(lang, L[i], x)) + return None + + +def _module(lang, L, x): + """x declared at the top of the file, or imported into it""" + ex = re.escape(x) + for i, line in enumerate(L): + if re.search(r'^\s*(?:import\s+(?:type\s+)?(?:\{[^}]*\b' + ex + r'\b[^}]*\}|' + ex + r'\b|\*\s+as\s+' + ex + r'\b|[\w$]+\s*,\s*\{[^}]*\b' + ex + r'\b)' + r'|import\s+(?:static\s+)?[\w.]+\.' + ex + r'\s*;|import\s+(?:[\w.]+\s+as\s+)?' + ex + r'\s*$|from\s+\S+\s+import\b[^#]*\b' + ex + r'\b' + r'|using\s+' + ex + r'\s*=|(?:const|let|var)\s+(?:\{[^}]*\b' + ex + r'\b[^}]*\}|' + ex + r')\s*=\s*require\b)', line): + return (i + 1, 'import', None) + for i, line in enumerate(L): + if re.search(r'^(?:export\s+)?(?:default\s+)?(?:(?:const|let|var)\s+' + ex + r'\b|(?:async\s+)?function\s*\*?\s*' + ex + r'\b|class\s+' + ex + r'\b|' + ex + r'\s*(?::[^=]+)?=(?!=)|def\s+' + ex + r'\b)', line): + return (i + 1, 'module', _type_in(lang, line, x)) + return None + + +def _root_decl(lang, L, x, n): + """where the name x, as written at line n, gets its value: a local or parameter above it, else a field, else the module""" + if x in ('this', 'self', 'cls', 'super', 'base'): return None + return _local(lang, L, x, n) or _param(lang, L, x, n) or _field(lang, L, x, n) or _module(lang, L, x) + + +KEY_ARG = re.compile(r'\b(?:\w+\.)*\w+\s*\(\s*(?:f?["\']([^"\']+)["\']|([A-Za-z_$][\w$.]*))\s*,') + + +def decide(src, at, name, lang=None): + """the deciding line of a non-exact call at `at` to a method called `name`: (line, text, kind) or None""" + f, n = split_at(at) + L = src.lines(f) if f else None + if not L or not 0 < n <= len(L): return None + lang = lang or lang_of(f) + # a call written over several lines: the name is on the line of the site or just below it + rows = [(k, L[k - 1]) for k in range(n, min(len(L), n + 3) + 1)] + for k, text in rows: + rv = receiver(text, name) if name else None + if not rv: continue + root, rest = parts(rv) + d = None + if root in ('this', 'self', 'base') and rest: + fld = re.sub(r'\(.*$', '', rest[0]) + d = _field(lang, L, fld, k) + if d: kind = d[1] if len(rest) == 1 else d[1] + ' ' + fld + elif root.endswith(')'): + fn = root.split('(')[0] + d = _root_decl(lang, L, fn, k) + if d: kind = 'returned by ' + fn + '()' + else: + d = _root_decl(lang, L, root, k) + if d: kind = d[1] if not rest else d[1] + ' ' + root + if d: + return (d[0], L[d[0] - 1], kind + (f" · type {d[2]}" if d[2] else '')) + return None + # no `.name(` on the line: a dispatch key or a registration decides which declaration runs + text = ' '.join(t for _k, t in rows[:2]) + g = re.search(r'getattr\s*\([^,]+,\s*f?["\']([^"\']*)["\']|getattr\s*\([^,]+,\s*(' + ID + r')', text) + if g: + ids = re.findall(r'\{(' + ID + r')', g.group(1) or '') or ([g.group(2)] if g.group(2) else []) + for x in ids: + d = _root_decl(lang, L, x, n) + if d: return (d[0], L[d[0] - 1], 'dispatch key ' + x) + return None + s = re.search(r'\[\s*(' + ID + r')\s*\]\s*\(', text) + if s: + d = _root_decl(lang, L, s.group(1), n) + if d: return (d[0], L[d[0] - 1], 'dispatch key ' + s.group(1)) + return None + if name and re.search(r'\b' + re.escape(name) + r'\b(?!\s*\()', text): + k = KEY_ARG.search(text) + if k and k.group(2): + x = k.group(2).split('.')[-1] if k.group(2).split('.')[0] in ('this', 'self') else k.group(2).split('.')[0] + d = _root_decl(lang, L, x, n) if k.group(2).split('.')[0] not in ('this', 'self') else _field(lang, L, x, n) + if d and d[1] == 'import': + far = _imported(src, f, L[d[0] - 1], x, lang) + if far: return far + ('registration key ' + k.group(2),) + if d: return (d[0], L[d[0] - 1], 'registration key ' + k.group(2)) + return None + + +def _imported(src, f, line, x, lang): + """(file:line, text) where a name imported from a module of this repository is defined, or None""" + m = re.search(r'from\s+[\'"](\.[^\'"]+)[\'"]', line) or re.search(r'require\(\s*[\'"](\.[^\'"]+)[\'"]', line) + cands = [] + if m: + base = os.path.normpath(os.path.join(os.path.dirname(f), m.group(1))) + cands = [base + e for e in ('', '.ts', '.tsx', '.js', '.mjs', '.jsx', '/index.ts', '/index.js')] + else: + m = re.search(r'^\s*from\s+(\.*)([\w.]*)\s+import\b', line) + if m: + up = os.path.dirname(f) + for _ in range(max(0, len(m.group(1)) - 1)): up = os.path.dirname(up) + rel = m.group(2).replace('.', '/') + cands = [os.path.join(up, rel + '.py'), os.path.join(up, rel, '__init__.py'), rel + '.py'] + for c in cands: + c = c.replace(os.sep, '/') + L2 = src.lines(c) if c.rsplit('.', 1)[-1] in ('ts', 'tsx', 'js', 'mjs', 'jsx', 'py') else None + if not L2: continue + d = _module(lang, L2, x) + if d and d[1] == 'module': return (f'{c}:{d[0]}', L2[d[0] - 1]) + return None + + +def evidence(src, at, name): + """{call: {at, text}, decider: {at, text, kind}} for one site; decider absent when no line decides it""" + f, n = split_at(at) + L = src.lines(f) if f else None + if not L or not 0 < n <= len(L): return None + k = n + if name: + # the site's own line when it writes the name, or is a call that does not (a dispatch through a key); else the + # site is where the callable starts (a decorator, a doc comment above it) and the call is the first line below + # that writes `.name(`, else the first that writes the name at all + own, t = re.search(r'\b' + re.escape(name) + r'\b', L[n - 1]), L[n - 1].strip() + if not own and not ('(' in t and not re.match(r'[@*/#]', t)): + near = range(n, min(len(L), n + 8) + 1) + k = next((j for j in near if re.search(r'\.\s*' + re.escape(name) + r'\s*(?:<[^()]*>)?\s*[(`]', L[j - 1])), None) \ + or next((j for j in range(n, min(len(L), n + 3) + 1) if re.search(r'\b' + re.escape(name) + r'\b', L[j - 1])), n) + ev = {'call': {'at': f'{f}:{k}', 'text': trim(L[k - 1])}} + try: d = decide(src, f'{f}:{k}', name) + except re.error: d = None + if d: + ev['decider'] = {'at': d[0] if isinstance(d[0], str) else f'{f}:{d[0]}', 'text': trim(d[1]), 'kind': d[2]} + return ev + + +# ── only through it ──────────────────────────────────────────────────────────────────────────────────────────────────── +def _up(callers_of, seeds, within): + seen, stack = set(), list(seeds) + while stack: + x = stack.pop() + for a in callers_of.get(x, ()): + if a in within and a not in seen: + seen.add(a); stack.append(a) + return seen + + +def only_through(callers_of, keep, row, within): + """the members of `within` reached upward from `row` and from none of `keep` (the other dependents): the row's own + callable among them, since asked again without the row it leaves the answer too""" + mine = _up(callers_of, [row], within) | ({row} & within) + if not mine: return set() + others = [k for k in keep if k != row] + return mine - _up(callers_of, others, within) - set(others) + + +def rank(rows, cert_rank): + """the strongest non-exact rows first: the surer rung, then the more the answer stands on it, then source order""" + return sorted(rows, key=lambda r: (cert_rank(r.get('certainty')), -((r.get('only_through') or {}).get('callables', 0) + + (r.get('only_through') or {}).get('tests', 0)), + r.get('at') or '')) + + +# ── one document ─────────────────────────────────────────────────────────────────────────────────────────────────────── +ORDER = ['one of a set', 'dispatch', 'registered', 'remote', 'capped set', 'in scope', 'spawns', 'by key', + 'decorator by name', 'protocol', 'fixture', 'by name', 'text'] + + +def cert_rank(c): + return ORDER.index(c) if c in ORDER else len(ORDER) + + +def attach(rows, src, name_of, top=TOP): + """evidence on the `top` strongest non-exact rows (already carrying only_through when it is known); returns how many + non-exact rows were left without it""" + weak = [r for r in rows if not is_exact(r.get('certainty'))] + chosen = rank(weak, cert_rank)[:top] + for r in chosen: + ev = evidence(src, r.get('call_at') or r.get('at'), name_of(r)) + if ev: r['evidence'] = ev + return max(0, len(weak) - len(chosen)) + + +def impact_doc(doc, repo, callers_of=None, test_via=None): + """annotate an impact --json document in place: only_through on every non-exact direct row (when the edges are + given; a test reached through a fixture counts with its fixture), evidence on the strongest five""" + names = [simple(t.get('label')) for t in doc.get('targets', [])] + names = [n for n in names if n] + src = Source(repo) + direct = doc.get('direct', []) + if callers_of is not None: + seeds = [r['id'] for r in direct if r.get('certainty') not in ('alongside', 'stubs it')] + [r['id'] for r in doc.get('contract', [])] + within = {r['id'] for r in doc.get('reached', [])} | {t['id'] for t in doc.get('tests', [])} + tests = {t['id'] for t in doc.get('tests', [])} + via = test_via or {} + for r in direct: + if is_exact(r.get('certainty')): continue + o = only_through(callers_of, seeds, r['id'], within) + ts = {t for t in tests if t in o or (via.get(t) and via[t] in o)} + r['only_through'] = {'callables': len(o - tests), 'tests': len(ts)} + def name_of(r): + f, n = split_at(r.get('at')) + L = src.lines(f) if f else None + line = (L[n - 1] if L and 0 < n <= len(L) else '') + return next((x for x in names if re.search(r'\b' + re.escape(x) + r'\b', line)), names[0] if names else '') + rest = attach(direct, src, name_of) + _more(doc, rest) + return doc + + +def path_doc(doc, repo): + src = Source(repo) + hops = [] + for a in doc.get('answers', []): + for h in a.get('hops', []): + h.setdefault('certainty', h.get('cert')) + hops.append(h) + import ax_edges + for h in hops: + h['certainty'] = 'defines (not a call)' if not h.get('is_call', True) else ax_edges.direct_cert(h.get('tier')) + _more(doc, attach(hops, src, lambda h: simple(h.get('to')))) + for h in hops: h.pop('certainty', None) + return doc + + +def context_doc(doc, repo): + src = Source(repo) + flow = doc.get('flow', []) + parent = {} + stack = [] + for s in flow: + while stack and stack[-1].get('depth', 0) >= s.get('depth', 0): stack.pop() + if stack and s.get('called_at_line'): + s['call_at'] = f"{split_at(stack[-1].get('at'))[0]}:{s['called_at_line']}" + stack.append(s) + rows = [s for s in flow if s.get('call_at') and not s.get('repeat_of')] + _more(doc, attach(rows, src, lambda s: simple(s.get('name')))) + for s in flow: s.pop('call_at', None) + return doc + + +def tests_doc(doc, repo): + """a test reached through a non-exact hop: the line in the test's body that starts the route, and what decides it""" + src = Source(repo) + changed = [simple(c.get('symbol') or c.get('target') or c.get('display') or '') for c in doc.get('changed', []) if isinstance(c, dict)] + rows = [] + for t in doc.get('tests', []): + if is_exact(t.get('certainty')) or t.get('certainty') == 'fixture': continue + ch = t.get('chain') or [] + want = [simple(ch[1])] if len(ch) > 1 else [x for x in changed if x] + f, n = split_at(t.get('at')) + L = src.lines(f) if f else None + if not L or not n: continue + for k in range(n, min(len(L), n + 80) + 1): + hit = next((w for w in want if w and re.search(r'\b' + re.escape(w) + r'\s*[(`<]', L[k - 1])), None) + if hit: + t['call_at'] = f'{f}:{k}'; t['_name'] = hit; rows.append(t); break + _more(doc, attach(rows, src, lambda t: t.get('_name', ''))) + for t in doc.get('tests', []): t.pop('call_at', None); t.pop('_name', None) + return doc + + +def _more(doc, n): + """the count of non-exact rows left without evidence, said only when there are some: an answer with none is unchanged""" + if n: doc['evidence_more'] = n + + +def annotate(verb, doc, repo, **kw): + """the one entry point for a --json document, and every other language's inside it""" + if not on() or not isinstance(doc, dict): return doc + f = {'impact': impact_doc, 'path': path_doc, 'context': context_doc, 'test-impact': tests_doc, 'tests': tests_doc}.get(verb) + if f: f(doc, repo, **kw) if verb == 'impact' else f(doc, repo) + return doc + + +# ── how it is printed ────────────────────────────────────────────────────────────────────────────────────────────────── +def lines(r, call=True, indent=' '): + """the evidence of one row as at most two lines (and a third, the only-through count and how to ask without it)""" + ev = r.get('evidence') or {} + out = [] + c = ev.get('call') or {} + # a line in the file the row already names is written L: the path is on the row + here = lambda at: 'L' + at.rpartition(':')[2] if at.rpartition(':')[0] == (r.get('at') or '').rpartition(':')[0] else at + # printed lines are shorter than the --json text: five rows of them are the whole cost of the evidence view + short = lambda s: s if len(s) <= PRINTED else s[:PRINTED - 1] + '…' + if call and c: out.append(f"{indent}call {here(c['at'])}: {short(c['text'])}") + d = ev.get('decider') + if d and d['at'] == c.get('at'): out.append(f"{indent}decided on the call's own line [{d['kind']}]") + elif d: out.append(f"{indent}decided {here(d['at'])}: {short(d['text'])} [{d['kind']}]") + # the row's own file:line is what --drop takes: it is on the row already, so it is said once, in DROP_HINT + o = r.get('only_through') + if o and (o.get('callables') or o.get('tests')): + out.append(f"{indent}only through it: {o['callables']} callable(s), {o['tests']} test(s)") + return out + + +DROP_HINT = "--drop asks again without that row and what stands only on it; --exact with exact edges only" + + +def prose_block(rows, more, what='rows'): + """the evidence section of a prose answer: the rows that carry evidence, each with its lines""" + ev = [r for r in rows if r.get('evidence')] + if not ev: return [] + out = [f"evidence for the {len(ev)} strongest non-exact {what} (the line that decides each; the rest are leads):"] + for r in ev: + out.append(f" [{r.get('certainty')}] {r.get('display') or r.get('name') or r.get('to') or ''} {r.get('at')}") + out += lines(r) + if more: out.append(f" +{more} more non-exact {what} without evidence (only the {TOP} strongest carry it)") + if any(r.get('only_through') for r in ev): out.append(' ' + DROP_HINT) + return out + + +def ask_again_note(): + """the line an answer asked again without some rows starts with""" + d, x = drops(), exact_only() + if not d and not x: return '' + return 'asked again ' + ('with exact edges only' if x else '') + (' and ' if x and d else '') + \ + (f"without {', '.join(sorted(d))}" if d else '') + ' — what was reached only through those rows is left out' + + +def dropped(r): + """is this direct row one the caller asked to leave out""" + d = drops() + if exact_only() and not is_exact(r.get('certainty')) and r.get('certainty') != 'alongside': return True + if not d: return False + return r.get('id') in d or r.get('at') in d or any(x.get('at') in d for x in r.get('reasons', []) or []) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py index 52d45d16..b87c214f 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py @@ -25,7 +25,7 @@ H = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, H) -import ax_edges +import ax_edges, ax_evidence CAP = 30 TEXT_ROWS = 5 # rows of a name written in a non-source file: leads, so a few and a count @@ -87,6 +87,20 @@ def more_in_file(k): return f" · +{k} more in this file" if k else '' +def ev(row, r): + """a row and, when it carries evidence (ax_evidence.py), the line that decides it under it: one entry, so a cap + never separates them""" + return row + ''.join('\n' + l for l in ax_evidence.lines(r, call=False)) if r.get('evidence') else row + + +def ev_foot(d): + n = d.get('evidence_more') + out = [f"evidence: the {ax_evidence.TOP} strongest non-exact rows carry the line that decides them, {n} more do not"] if n else [] + rows = d.get('direct', []) + d.get('tests', []) + [h for a in d.get('answers', []) for h in a.get('hops', [])] + if any(r.get('only_through') for r in rows if isinstance(r, dict)): out.append(ax_evidence.DROP_HINT) + return out + + # ── impact ─────────────────────────────────────────────────────────────────────────────────────────────────────────── def impact(d, code): rows, rest = [], {} @@ -95,11 +109,19 @@ def more(k, n=1): rest[k] = rest.get(k, 0) + n rows.append(('contract', site(code, r['at'], f"must change · {r['why']}{stale(r)}", r['display']))) seen = set() if d.get('alongside'): more('alongside (no call, no reference)', len(d['alongside'])) - for r in d.get('direct', []): + if d.get('alongside_count'): more('alongside (no call, no reference; --alongside lists them)', d['alongside_count']) + if d.get('asked_again'): rows.append(('note', d['asked_again']['note'])) + # a row with evidence leads its rung: it is one of the strongest, and the cap must not cut it from its evidence + direct = d.get('direct', []) + if any(r.get('evidence') for r in direct): + rung = {} + for i, r in enumerate(direct): rung.setdefault(r.get('certainty'), i) + direct = sorted(direct, key=lambda r: (rung[r.get('certainty')], not r.get('evidence'))) + for r in direct: cert = r.get('certainty') or 'resolved' if cert == 'alongside': more('alongside (no call, no reference)'); continue seen.add(r['id']) - rows.append((cert, site(code, r['at'], f"{TAG.get(cert, cert)}{n_sites(r)}{stale(r)}", r['display']))) + rows.append((cert, ev(site(code, r['at'], f"{TAG.get(cert, cert)}{n_sites(r)}{stale(r)}", r['display']), r))) tests = {t['id'] for t in d.get('tests', [])} # a module's top level reaches it too, but its row is the file's first line (an import), which says nothing: those # come after the tests, so a cap spends its lines on callables and on what to run @@ -127,6 +149,7 @@ def more(k, n=1): rest[k] = rest.get(k, 0) + n foot = [] nt = len(d.get('tests', [])) if 'test_universe' in d: foot.append(f"tests: {nt} of {d['test_universe']} reach it" + ("; `axiomcode test-impact` runs them" if nt else '')) + foot += ev_foot(d) foot.append(verified(d.get('verified'), d.get('checked_hops'))) if d.get('unresolved_inside'): foot.append(f"bound: {d['unresolved_inside']} unresolved call(s) inside — a lower bound") return rows, rest, foot @@ -142,7 +165,7 @@ def path(d, code): cert = 'defines (not a call)' if not h.get('is_call', True) else ax_edges.direct_cert(t) at = h.get('call_at') or h.get('declared_at') # caller → callee on every hop: the line is in the caller, and a chain found from B back to A reads right - rows.append(('hop', site(code, at, f"{cert} · hop {i}/{len(hops)} {prev} → {h['to']}{stale(h)}"))) + rows.append(('hop', ev(site(code, at, f"{cert} · hop {i}/{len(hops)} {prev} → {h['to']}{stale(h)}"), h))) prev = h['to'] for r in d.get('reached', []): rows.append(('reached', site(code, r['at'], f"hop {r['hops']}{stale(r)}", r['name']))) @@ -154,7 +177,7 @@ def path(d, code): ans = d.get('answers', []) v = d.get('verified') if ans: v = all(not a.get('unverified_hops') for a in ans) and v is not False - foot = [verified(v, sum(len(a.get('hops', [])) for a in ans) if ans else None)] + foot = ev_foot(d) + [verified(v, sum(len(a.get('hops', [])) for a in ans) if ans else None)] if d.get('bound'): foot.append(f"bound: {d['bound']}") return rows, {}, foot @@ -169,7 +192,7 @@ def context(d, code): tag = f"step {s['step']} · {'entry' if c == 'entry' else TAG.get(c, c)}" if s.get('called_at_line'): tag += f" · called at L{s['called_at_line']}" if s.get('unresolved'): tag += ' · ⚠ ' + ', '.join(s['unresolved'][:2]) - rows.append(('flow', site(code, s['at'], tag + stale(s), s['name']))); listed.add(s['at']) + rows.append(('flow', ev(site(code, s['at'], tag + stale(s), s['name']), s))); listed.add(s['at']) if not d.get('flow'): for e in d.get('entry_points', []): if e['at'] in listed: continue @@ -181,7 +204,7 @@ def context(d, code): tb = d.get('text_bindings', []) for t in tb[:TEXT_ROWS]: rows.append(('text', site(code, f"{t['file']}:{t['line']}", 'text · names ' + (t.get('name') or ', '.join(t.get('terms', []))) + stale(t)))) - foot = [] + foot = ev_foot(d) if d.get('not_indexed'): foot.append('not indexed: ' + ', '.join(map(str, d['not_indexed'][:3]))) foot.append("bound: follows call edges and names; a constant, config key, string or reflection does not appear") return rows, ({'[text]': len(tb) - TEXT_ROWS} if len(tb) > TEXT_ROWS else {}), foot @@ -201,7 +224,7 @@ def test_impact(d, code): for f in d.get('edited_test_files', []): rows.append(('test', f"{f}:1: (edited test file) [test · edited]")) for t, k in per_test_file(d.get('tests', [])): - rows.append(('test', site(code, t['at'], f"test · {TAG.get(t.get('certainty'), t.get('certainty'))} · hop {t['hops']}{more_in_file(k)}{stale(t)}", t['display']))) + rows.append(('test', ev(site(code, t['at'], f"test · {TAG.get(t.get('certainty'), t.get('certainty'))} · hop {t['hops']}{more_in_file(k)}{stale(t)}", t['display']), t))) # a changed file no graph follows (a script, a fixture) is run by the test files that name it in their text names = {} for f, v in (d.get('named_in_test_text') or {}).items(): @@ -209,7 +232,7 @@ def test_impact(d, code): names.setdefault(t, []).append((v or {}).get('needle') or f) for t, ns in names.items(): rows.append(('text test', f"{t}:1: (names {', '.join(dict.fromkeys(ns))}) [test · text]")) - foot = [] + foot = ev_foot(d) if d.get('command'): foot.append(f"run: {d['command']}") if d.get('bound'): foot.append(f"bound: {d['bound']}") return rows, {}, foot diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode index 92404610..c503f33d 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode @@ -66,6 +66,10 @@ # --grep (context, path, impact, test-impact) prints the answer's sites one per line, as grep does: `path:line: [resolved | one of a set | by name | text | hop N | test]`, the first 30 (--grep-limit N) and a count of # the rest, then whether it was verified and its bound. The MCP tools answer this way by default; full=True is the prose. +# --evidence / --no-evidence (or AXIOMCODE_EVIDENCE=on|off; off by default): each of the five strongest rows that is not an +# exact edge ([by name], [one of a set], [registered], …) carries the line that decides it — where its receiver or key +# gets its value — and how much of the answer is reached only through it. --drop (repeatable) asks again +# without that row, --exact with exact edges only; --alongside lists the `alongside` rows the evidence view counts. # ON WINDOWS $0 CAN MIX SEPARATORS: the MCP server joins its script path onto AXIOMCODE_PLUGIN_ROOT with os.path.join, # C:/.../plugins/axiomcode\skills\axiomcode\scripts\axiomcode. Splitting that on '/' alone lands on .../plugins, and # every verb then runs a helper that is not there. `dirname` split on either; so does this, as bin/axiomcode does. @@ -123,6 +127,12 @@ while [ $# -gt 0 ]; do --no-refresh) export AXIOMCODE_NO_REFRESH=1; shift ;; --grep) GREP=1; shift ;; --grep-limit) GREP=1; GREP_LIMIT="$2"; shift 2 ;; + # evidence for the uncertain rows, and asking again without them (ax_evidence.py): read by every verb from the env + --evidence) export AXIOMCODE_EVIDENCE=on; shift ;; + --no-evidence) export AXIOMCODE_EVIDENCE=off; shift ;; + --drop) export AXIOMCODE_DROP="${AXIOMCODE_DROP:+$AXIOMCODE_DROP,}$2"; shift 2 ;; + --exact) export AXIOMCODE_EXACT=1; shift ;; + --alongside) export AXIOMCODE_ALONGSIDE=1; shift ;; *) ARGS+=("$1"); shift ;; esac done diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context index 7052d34c..14ec00d6 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context @@ -1321,14 +1321,21 @@ def cli(argv): """--json: the same answer as one document. The prose is produced by the same code path and carried in `prose`, so the machine shape is never LESS than the human one — a second traversal to build it would be a second implementation to keep in step.""" + import ax_evidence + repo = next((a for a in reversed([x for x in argv if not x.startswith('--')]) if os.path.isdir(a)), '.') if '--json' not in argv: code = main(argv) + # the line that decides each flow step reached through a call that is not an exact edge (ax_evidence.py) + if ax_evidence.on() and RESULT.get('flow'): + ax_evidence.annotate('context', RESULT, repo) + for l in ax_evidence.prose_block(RESULT['flow'], RESULT.get('evidence_more', 0), 'steps'): print(l) if code in (0, 2): quoted_text(argv) sys.exit(code) import contextlib, io, json buf = io.StringIO() with contextlib.redirect_stdout(buf): code = main(argv) RESULT['prose'] = buf.getvalue().rstrip('\n').split('\n') + ax_evidence.annotate('context', RESULT, repo) print(json.dumps(RESULT, indent=2)) sys.exit(code) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index fbd91552..1363e354 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -66,7 +66,7 @@ def prof(what): if PROF: print(f" [{time.time() - _t0:5.1f}s] {what}", file=sys.stderr) HERE = os.path.dirname(os.path.abspath(__file__)) -sys.path.insert(0, HERE); import dl_program, ax_registration, ax_edges, ax_text, ax_spawn +sys.path.insert(0, HERE); import dl_program, ax_registration, ax_edges, ax_text, ax_spawn, ax_evidence _spec = importlib.util.spec_from_loader('axpath', importlib.machinery.SourceFileLoader('axpath', os.path.join(HERE, 'axiomcode-path'))) P = importlib.util.module_from_spec(_spec); _spec.loader.exec_module(P) G, run_dl, die, BODILESS_KINDS = P.G, P.run_dl, P.die, P.BODILESS_KINDS @@ -2399,6 +2399,14 @@ def main(argv): parent = collections.defaultdict(list) for a, b, t, q_ in res['parent_up']: parent[a].append((b, t)) prof('outputs read') + # who calls each callable, for what stands ONLY on a row (ax_evidence.only_through): built on first use, so an answer + # with evidence off and no --drop never pays for it + _callers_memo = [] + def callers_of(): + if not _callers_memo: + _callers_memo.append(collections.defaultdict(set)) + for a, b, _ in g.edges(): _callers_memo[0][b].add(a) + return _callers_memo[0] tests = {} for m, d, q_ in res['test_near']: if m in g.sym and int(d) <= depth and int(d) < tests.get(m, (10 ** 9,))[0]: @@ -2427,6 +2435,18 @@ def main(argv): stubbed = sorted({x[0] for x in res.get('test_stub', []) if x[0] in g.sym and x[0] not in tests}, key=lambda m: (g.sym[m]['file'] or '', g.sym[m]['display'] or '', m)) byname_seeds = {x[0] for x in res['seed_byname'] if x[0] in g.sym} + # ASKED AGAIN WITHOUT SOME ROWS (--drop , --exact; ax_evidence.py): the rows go, and with them what the + # answer reached ONLY through them -- the callables and tests no other dependent reaches. The rest is untouched. + _row = lambda x: {'id': x[0], 'at': x[4], 'certainty': x[3], 'reasons': direct_reasons.get((x[0], _grp(x[1])), [])} + _dropped = [x for x in D if ax_evidence.dropped(_row(x))] + if _dropped: + _keep = [x[0] for x in D if x not in _dropped and x[3] not in ('alongside', 'stubs it')] + [c for c, _ in contract] + _within = set(reached) | set(tests) + _lost = set().union(*(ax_evidence.only_through(callers_of(), _keep, x[0], _within) for x in _dropped)) + D = [x for x in D if x not in _dropped] + reached = {m: d for m, d in reached.items() if m not in _lost} + tests = {m: v for m, v in tests.items() if m not in _lost and not (v[1] and v[1] in _lost)} + byname_seeds -= {x[0] for x in _dropped} - set(_keep) # the callers on an untyped receiver are SAMPLED in the text answer, so their order decides which are shown. Symbol # ids are not stable between two builds of the same tree, so ordering by id showed a different sample each build: # they go in source order (file, line, then name), which only an edit changes. @@ -2668,7 +2688,7 @@ def main(argv): fw_grep = ("grep -rnF " + ' '.join(f"-e '\"{p_}'" for p_ in pre) + f" {P.grep_roots(g)}") if pre else \ ("grep -rnw " + ' '.join(f'-e "{n}"' for n in names) + f" {P.grep_roots(g)}" if names else '') if as_json: - print(json.dumps({'targets': [{'kind': k, 'label': lab} for k, lab, _ in targets], + doc = ({'targets': [{'kind': k, 'label': lab} for k, lab, _ in targets], 'contract': [{'id': c, 'display': g.disp(c), 'why': why, 'also': contract_also.get(c, []), 'at': g.loc(c), 'for': sorted(contract_for[c])} for c, why in contract], # the sort key ends in x[0], the id: two distinct anon-class methods can share a # display AND every other component of the key, and without the id the order is @@ -2689,11 +2709,19 @@ def main(argv): 'byname_callers': [{'id': c, 'display': g.disp(c), 'at': g.loc(c)} for c in sorted(byname_seeds, key=by_place)], 'external': [{'at': f'{f}:{l}', 'name': n, 'how': how, **({'prose': True} if (n, f, l) in prose_hits else {})} for f, l, n, how in sorted({(r[0], int(r[1]), r[2], r[3]) for r in res.get('extbind', [])})], 'test_universe': universe, **({'tests_outside_src': dict(zip(('src', 'files'), out_src))} if out_src[1] else {}), 'verified': bad == 0, 'checked_hops': hops, 'unresolved_inside': u, - **({'why': [g.why_json(n) for n in target_why]} if want_why else {})}, indent=1)) + **({'why': [g.why_json(n) for n in target_why]} if want_why else {})}) + if ax_evidence.on() or _dropped: + if _dropped: doc['asked_again'] = {'note': ax_evidence.ask_again_note(), 'dropped': [g.loc(x[0]) if not x[4] else x[4] for x in _dropped]} + if ax_evidence.on(): + ax_evidence.impact_doc(doc, g.repo, callers_of(), {m: fx for m, (_d, fx) in tests.items()}) + if not ax_evidence.list_alongside() and doc.get('alongside') and any(not ax_evidence.is_exact(r['certainty']) for r in doc['direct']): + doc['alongside_count'] = len(doc.pop('alongside')) + print(json.dumps(doc, indent=1)) return 0 for i, (k, lab, _) in enumerate(targets): print(f"change: {lab} [{k}]") if want_why and i < len(target_why): print('\n'.join(g.why_lines(target_why[i]))) + if _dropped: print(ax_evidence.ask_again_note() + f" ({len(_dropped)} row(s))") # --tests-only: every section is still COMPUTED (the tests are derived from them) but only the tests section, # and the verified / bound lines that qualify it, are printed _stdout = sys.stdout @@ -2870,8 +2898,13 @@ def main(argv): if len(own) == 1 and own[0][0] == 'method': return f'grep -rn "\\.{own[0][1]}(" {P.grep_roots(g)}' return 'grep -rnw ' + ' '.join(f'-e "{n}"' for _, n in own) + f' {P.grep_roots(g)}' + _ev_view = ax_evidence.on() and not ax_evidence.list_alongside() and any(not ax_evidence.is_exact(x[3]) for x in D) for title, rows in groups: if not rows: continue + # with evidence on, in an answer that has an uncertain row, the `alongside` section (no call, no reference) is a + # count, not rows: --alongside lists them. An answer of exact rows alone is left exactly as it was. + if _ev_view and all(r[3] == 'alongside' for r in rows): + print(f"{title}: {len({c for c, *_ in rows})} callable(s) — --alongside lists them"); continue # PRODUCTION BEFORE TESTS. A test that reaches the target is a CONSEQUENCE of the change, not a place that # has to be edited, and in a well-tested tree the tests outnumber the production callers several times over. # Ranking by certainty alone therefore interleaved them, and since the window is finite the file that @@ -2940,6 +2973,14 @@ def main(argv): tbyf = collections.Counter((r[4] or '').rsplit(':', 1)[0] for r in tst if r[4]) where = (f" in {len(tbyf)} file(s): " + ', '.join(f"{f} ({n})" for f, n in tbyf.most_common(4)) + (' …' if len(tbyf) > 4 else '')) if tbyf else '' print(f" +{len({c for c, *_ in tst})} test callable(s){where} also reach it, not listed here — `axiomcode test-impact` names them and the command that runs them (--tests lists them by file)") + if ax_evidence.on(): + # THE LINE THAT DECIDES EACH UNCERTAIN ROW (ax_evidence.py), for the five strongest, with what stands on it + _ed = ax_evidence.impact_doc({'targets': [{'label': lab} for k, lab, _ in targets], + 'direct': [{'id': c, 'display': g.disp(c), 'certainty': cert, 'at': loc} for c, role, why, cert, loc, n in D], + 'contract': [{'id': c} for c, _ in contract], + 'reached': [{'id': m} for m in reached], 'tests': [{'id': m} for m in tests]}, + g.repo, callers_of(), {m: fx for m, (_d, fx) in tests.items()}) + for l in ax_evidence.prose_block(_ed['direct'], _ed.get('evidence_more', 0)): print(l) nfiles = len({g.sym[m]['file'] for m in reached}) if any(k == 'var' for k, _, _ in targets) and not reached: print("reaches those through resolved calls: nothing — a local lives inside its method; it escapes only through what the method returns or writes, and the callers of the method are its impact only if it does") diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index 0cd36133..3c28d0a1 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -2127,11 +2127,19 @@ if __name__ == '__main__': if e.code and UNRESOLVED and not as_json: sys.stdout.flush(); ax_text.emit(repo, UNRESOLVED, IN) raise - if not as_json: sys.exit(answer()) + import ax_evidence + if not as_json: + code = answer() + # the line that decides each hop that is not an exact edge (ax_evidence.py), after the chains it qualifies + if ax_evidence.on() and isinstance(RESULT.get('query'), dict): + ax_evidence.annotate('path', RESULT, RESULT['query'].get('repo') or '.') + for l in ax_evidence.prose_block([h for a in RESULT.get('answers', []) for h in a.get('hops', [])], RESULT.get('evidence_more', 0), 'hops'): print(l) + sys.exit(code) # the prose goes to a buffer, not to the caller: one code path produces both, and `prose` is # carried in the document so a machine answer is never LESS than the human one. buf = io.StringIO() with contextlib.redirect_stdout(buf): code = answer() RESULT['prose'] = buf.getvalue().rstrip('\n').split('\n') + ax_evidence.annotate('path', RESULT, (RESULT.get('query') or {}).get('repo') or '.') print(json.dumps(RESULT, indent=2)) sys.exit(code) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact index d984ad6c..d086a90d 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact @@ -2061,8 +2061,9 @@ def main(argv): replaced = concrete_test_classes(db, run_classes)[1] if lang in ('java', 'csharp') else {} cmd_all = command_for(lang, run_files, run_classes + ([os.path.splitext(os.path.basename(f))[0] for f in edited] if classes else []), db, repo) + import ax_evidence if as_json: - print(json.dumps({'changed': [{'symbol': by_target[t]['symbol'], 'kind': by_target[t]['kind'], + print(json.dumps(ax_evidence.annotate('test-impact', {'changed': [{'symbol': by_target[t]['symbol'], 'kind': by_target[t]['kind'], 'target': t} for t in targets], 'test_files': files, 'test_classes': classes, 'tests': [dict(r, pulled_in_by=sorted(pulled[i])) for i, r in tests.items()], @@ -2085,7 +2086,7 @@ def main(argv): 'range_note': changed.get('range_note'), 'baseline_note': changed.get('baseline_note'), 'same_name_not_tests': name_hits.get('not_tests') or 0, 'bound': 'a lower bound: a test reached only by reflection, a service loader, a ' - 'framework instantiating by name, a subprocess, or a case built at runtime is not here'}, + 'framework instantiating by name, a subprocess, or a case built at runtime is not here'}, repo), indent=1)) return 0 @@ -2240,6 +2241,11 @@ def main(argv): for t, first in unreadable: print(f" {t} {(first or [''])[0][:100]}") print(" the selection above is incomplete — do not act on it as though it were the whole answer.") + if ax_evidence.on(): + # the line in each test that starts a route which is not an exact edge, and what decides it (ax_evidence.py) + _ed = ax_evidence.annotate('test-impact', {'changed': [{'symbol': by_target[t]['symbol']} for t in targets], + 'tests': [dict(r) for r in tests.values()]}, repo) + for l in ax_evidence.prose_block(_ed['tests'], _ed.get('evidence_more', 0), 'test routes'): print(l) print("\nbound: this is a LOWER bound. A test that reaches the change only through something the graph " "does not encode — reflection, a service loader, a framework that instantiates by name, a subprocess, a case " "built at runtime — does not appear here. Run these first; do not skip the rest on this alone.") diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/case.json b/tests/cases/typescript/evidence-for-uncertain-rows/case.json new file mode 100644 index 00000000..c301fdb6 --- /dev/null +++ b/tests/cases/typescript/evidence-for-uncertain-rows/case.json @@ -0,0 +1,88 @@ +{ + "lang": "typescript,javascript,python,java,csharp", + "src": ".", + "checks": [ + { + "why": "a [by name] row carries the line that decides its receiver: a Map field and an `any` parameter show the name-match is another type; the resolved row gets no evidence", + "run": ["impact", "ts/orders.ts:2", "--evidence", "--grep"], + "want": ["ts/callers.ts:16: return this.seen.get(key);", "decided L14: private readonly seen = new Map(); [field · type new Map(…)]", + "decided L20: export function viaAny(box: any, id: string): string { [param · type any]", "only through it:"], + "avoid": ["decided L7"] + }, + { + "why": "a dispatch-key row decides on where the key comes from, and a registration row on the constant it registers under, followed into the module that defines it", + "run": ["impact", "ts/bus.ts:16", "--evidence", "--grep"], + "want": ["decided L10: emit(topic: string, id: string): void { [dispatch key topic]", + "decided ts/callers.ts:4: export const ORDER_PLACED = 'order.placed'; [registration key ORDER_PLACED]"] + }, + { + "why": "--json rows gain evidence {call, decider} and only_through {callables, tests}; alongside rows become a count", + "run": ["impact", "ts/orders.ts:2", "--evidence", "--json"], + "stdout_json": true, + "want": ["\"evidence\"", "\"decider\"", "\"only_through\"", "\"callables\"", "\"alongside_count\""] + }, + { + "why": "the prose answer says the same, in one section after the rows it qualifies", + "run": ["impact", "ts/orders.ts:2", "--evidence"], + "want": ["evidence for the 2 strongest non-exact rows", "call L16: return this.seen.get(key);", "only through it: 1 callable(s), 0 test(s)", "--drop asks again without that row"] + }, + { + "why": "--exact asks again with exact edges only: the name-matches and what stands on them go, the resolved caller stays", + "run": ["impact", "ts/orders.ts:2", "--exact", "--grep"], + "want": ["return this.store.get(id);", "asked again with exact edges only"], + "avoid": ["box.get(id)", "this.seen.get(key)"] + }, + { + "why": "--drop asks again without that one row", + "run": ["impact", "ts/orders.ts:2", "--drop", "ts/callers.ts:21", "--grep"], + "want": ["this.seen.get(key)", "without ts/callers.ts:21"], + "avoid": ["box.get(id)"] + }, + { + "why": "control: an answer with only [resolved] rows is byte-identical with evidence on and off", + "run": ["impact", "ts/ledger.ts:2", "--evidence"], + "same_as": ["impact", "ts/ledger.ts:2", "--no-evidence"], + "want": ["[resolved] sum"] + }, + { + "why": "control: the same resolved-only answer as --json and as grep rows is byte-identical too", + "run": ["impact", "ts/ledger.ts:2", "--evidence", "--json"], + "same_as": ["impact", "ts/ledger.ts:2", "--json"], + "stdout_json": true + }, + { + "why": "control: evidence is off by default, so an answer with uncertain rows is the answer it was", + "run": ["impact", "ts/orders.ts:2", "--grep"], + "same_as": ["impact", "ts/orders.ts:2", "--no-evidence", "--grep"], + "avoid": ["decided ", "only through it"] + }, + { + "why": "JavaScript: an untyped parameter is the decider of a name-match; a field assigned in the constructor decides nothing that is resolved", + "run": ["impact", "js/store.js:2", "--evidence", "--grep"], + "want": ["decided L11: cached(res) { [param]", "decided L16: export function remote(api) { [param]"], + "avoid": ["this.shelf = new Shelf()"] + }, + { + "why": "Python: the receiver's type is set from an __init__ parameter of its OWN class, not the same-named attribute another class in the file annotates", + "run": ["impact", "py/gateway.py:2", "--evidence", "--grep"], + "want": ["py/billing.py:33: return self.gw.charge(-amount)", "decided L30: self.gw = gw [assigned]"], + "avoid": ["def __init__(self, gw: Gateway): [constructor parameter"] + }, + { + "why": "Java: a one-of-a-set call through a JDK functional interface decides on the field that holds it", + "run": ["impact", "java/app/Pipeline.java:7", "--evidence", "--grep"], + "want": ["decided L6: private final Function loader; [field · type Function]"] + }, + { + "why": "Java: path's hop through the same call carries the same decider", + "run": ["path", "Cache.load", "Pipeline.apply", "--evidence", "--grep"], + "want": ["private final Function loader;"] + }, + { + "why": "C#: a dynamic field is why the call is a name-match; the resolved call on the typed field beside it gets nothing", + "run": ["impact", "cs/Stock.cs:5", "--evidence", "--grep"], + "want": ["decided L11: private readonly dynamic _meter; [field · type dynamic]"], + "avoid": ["decided L10"] + } + ] +} diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/cs/Stock.cs b/tests/cases/typescript/evidence-for-uncertain-rows/cs/Stock.cs new file mode 100644 index 00000000..bd7a4df7 --- /dev/null +++ b/tests/cases/typescript/evidence-for-uncertain-rows/cs/Stock.cs @@ -0,0 +1,19 @@ +namespace App +{ + public class Stock + { + public int Count(string sku) { return sku.Length; } + } + + public class Counter + { + private readonly Stock _stock = new Stock(); + private readonly dynamic _meter; + + public Counter(dynamic meter) { _meter = meter; } + + public int Total(string sku) { return _stock.Count(sku); } + + public int Metered(string sku) { return _meter.Count(sku); } + } +} diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/java/app/Cache.java b/tests/cases/typescript/evidence-for-uncertain-rows/java/app/Cache.java new file mode 100644 index 00000000..9bd696b4 --- /dev/null +++ b/tests/cases/typescript/evidence-for-uncertain-rows/java/app/Cache.java @@ -0,0 +1,20 @@ +package app; + +import java.util.function.Function; + +public class Cache { + private final Function loader; + private final Store store = new Store(); + + public Cache(Function loader) { + this.loader = loader; + } + + public String read(String id) { + return store.lookup(id); + } + + public String load(String id) { + return loader.apply(id); + } +} diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/java/app/Pipeline.java b/tests/cases/typescript/evidence-for-uncertain-rows/java/app/Pipeline.java new file mode 100644 index 00000000..dd33065e --- /dev/null +++ b/tests/cases/typescript/evidence-for-uncertain-rows/java/app/Pipeline.java @@ -0,0 +1,10 @@ +package app; + +import java.util.function.Function; + +public class Pipeline implements Function { + @Override + public String apply(String s) { + return s.trim(); + } +} diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/java/app/Store.java b/tests/cases/typescript/evidence-for-uncertain-rows/java/app/Store.java new file mode 100644 index 00000000..132c2dc8 --- /dev/null +++ b/tests/cases/typescript/evidence-for-uncertain-rows/java/app/Store.java @@ -0,0 +1,7 @@ +package app; + +public class Store { + public String lookup(String id) { + return "store:" + id; + } +} diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/js/client.js b/tests/cases/typescript/evidence-for-uncertain-rows/js/client.js new file mode 100644 index 00000000..99274be4 --- /dev/null +++ b/tests/cases/typescript/evidence-for-uncertain-rows/js/client.js @@ -0,0 +1,18 @@ +import { Shelf } from './store.js'; + +export class Client { + constructor() { + this.cache = new Map(); + this.shelf = new Shelf(); + } + load(id) { + return this.shelf.fetch(id); + } + cached(res) { + return res.fetch('x'); + } +} + +export function remote(api) { + return api.fetch('/orders'); +} diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/js/store.js b/tests/cases/typescript/evidence-for-uncertain-rows/js/store.js new file mode 100644 index 00000000..7721cbe8 --- /dev/null +++ b/tests/cases/typescript/evidence-for-uncertain-rows/js/store.js @@ -0,0 +1,5 @@ +export class Shelf { + fetch(id) { + return 'shelf:' + id; + } +} diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/py/billing.py b/tests/cases/typescript/evidence-for-uncertain-rows/py/billing.py new file mode 100644 index 00000000..c423f8cb --- /dev/null +++ b/tests/cases/typescript/evidence-for-uncertain-rows/py/billing.py @@ -0,0 +1,33 @@ +from gateway import Gateway, Mailer + + +class Billing: + def __init__(self, gw: Gateway): + self.gw = gw + + def bill(self, amount): + return self.gw.charge(amount) + + +class Router: + def __init__(self, handlers): + self.handlers = handlers + + def on_refund(self, event): + return event + + def route(self, event): + kind = event["kind"] + return getattr(self, f"on_{kind}")(event) + + +def settle(m: Mailer): + return m.charge(5) + + +class Refunds: + def __init__(self, gw): + self.gw = gw + + def undo(self, amount): + return self.gw.charge(-amount) diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/py/gateway.py b/tests/cases/typescript/evidence-for-uncertain-rows/py/gateway.py new file mode 100644 index 00000000..f9f26732 --- /dev/null +++ b/tests/cases/typescript/evidence-for-uncertain-rows/py/gateway.py @@ -0,0 +1,8 @@ +class Gateway: + def charge(self, amount): + return amount + + +class Mailer: + def charge(self, amount): + return -amount diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/ts/bus.ts b/tests/cases/typescript/evidence-for-uncertain-rows/ts/bus.ts new file mode 100644 index 00000000..13fd6ace --- /dev/null +++ b/tests/cases/typescript/evidence-for-uncertain-rows/ts/bus.ts @@ -0,0 +1,21 @@ +import { ORDER_PLACED } from './callers'; + +type Handler = (id: string) => void; + +export class Bus { + private readonly handlers: Record = {}; + on(topic: string, handler: Handler): void { + this.handlers[topic] = handler; + } + emit(topic: string, id: string): void { + this.handlers[topic](id); + } +} + +export function onPlaced(id: string): void { + console.log(id); +} + +export function wire(bus: Bus): void { + bus.on(ORDER_PLACED, onPlaced); +} diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/ts/callers.ts b/tests/cases/typescript/evidence-for-uncertain-rows/ts/callers.ts new file mode 100644 index 00000000..e17afae3 --- /dev/null +++ b/tests/cases/typescript/evidence-for-uncertain-rows/ts/callers.ts @@ -0,0 +1,26 @@ +import { OrderStore } from './orders'; +import { Ledger } from './ledger'; + +export const ORDER_PLACED = 'order.placed'; + +export class Report { + constructor(private readonly store: OrderStore) {} + line(id: string): string { + return this.store.get(id); + } +} + +export class Memo { + private readonly seen = new Map(); + recall(key: string): string | undefined { + return this.seen.get(key); + } +} + +export function viaAny(box: any, id: string): string { + return box.get(id); +} + +export function sum(ledger: Ledger): number { + return ledger.total(3); +} diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/ts/ledger.ts b/tests/cases/typescript/evidence-for-uncertain-rows/ts/ledger.ts new file mode 100644 index 00000000..04a5c1fa --- /dev/null +++ b/tests/cases/typescript/evidence-for-uncertain-rows/ts/ledger.ts @@ -0,0 +1,5 @@ +export class Ledger { + total(n: number): number { + return n * 2; + } +} diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/ts/orders.ts b/tests/cases/typescript/evidence-for-uncertain-rows/ts/orders.ts new file mode 100644 index 00000000..5b1abdb7 --- /dev/null +++ b/tests/cases/typescript/evidence-for-uncertain-rows/ts/orders.ts @@ -0,0 +1,5 @@ +export class OrderStore { + get(id: string): string { + return 'order:' + id; + } +} diff --git a/tests/run.py b/tests/run.py index 75fcd456..a35f564a 100755 --- a/tests/run.py +++ b/tests/run.py @@ -14,7 +14,9 @@ it. A check fails loudly with the line that was wrong, so a regression names itself. No corpus, no network, nothing outside the case directory. -Three other keys a check may carry: +Four other keys a check may carry: + + "same_as": [run …] STDOUT must be byte-identical to that other run's: a switch that must leave an answer alone. "stdout_json": true STDOUT ALONE must parse as one JSON document. `want` and `avoid` read stdout and stderr CONCATENATED, so no substring can express "this must not be inside the document" — which is @@ -70,6 +72,14 @@ # not do this yet", not "anything may happen here". A crash, a missing fixture or an unreadable answer under # a marker would otherwise be indistinguishable from the gap it names, and the marker becomes the hiding # place this mechanism exists to remove. + # "same_as": [run …] — STDOUT must be BYTE-IDENTICAL to another run's: a switch that must not change an answer + # (evidence on an answer with no uncertain row, a default that is off) is a comparison, not a substring + if ch.get('same_as'): + other = subprocess.run(['bash', AX] + [a.replace('{repo}', path) for a in ch['same_as']] + [path], capture_output=True, text=True) + if other.stdout != out.stdout: + a_, b_ = out.stdout.split('\n'), other.stdout.split('\n') + i = next((i for i, (x, y) in enumerate(zip(a_, b_)) if x != y), min(len(a_), len(b_))) + bad.append(f"stdout differs from {' '.join(ch['same_as'])} at line {i + 1}: {a_[i] if i < len(a_) else '(end)'!r} vs {b_[i] if i < len(b_) else '(end)'!r}") crashed = bool(out.returncode) and not ch.get('expect_error') if crashed: bad.append(f"(exit {out.returncode})") # "pending": "" — a case that states behaviour the tool does NOT have yet. It still RUNS, and the two