From 88aa1573878a3c8acb3195529e7a91cc6a148bce Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:33:01 -0700 Subject: [PATCH] impact: rows declared alongside a target are listed apart, never as its dependents A same-file or same-type row (certainty alongside: no call, no reference) was emitted in --json direct with role 'uses', printed and counted under 'reads or uses it', and listed by the Edit hook under 'reads / uses it (N)'. Anything keyed on the section or role took co-location for a dependency, and the answer flipped when a type moved to another file. - --json: those rows move to their own 'alongside' list, role 'co-located'; 'direct' holds dependents only - text: a separate 'declared alongside it, not a dependent' section; with nothing else, the answer says nothing touches it - Edit hooks drop them from 'reads / uses it', and each row there carries its rung - skill: the [alongside] rung is in the certainty table Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- plugins/axiomcode/hooks/changes.py | 2 +- plugins/axiomcode/hooks/enrich.py | 6 +++-- plugins/axiomcode/skills/axiomcode/SKILL.md | 1 + .../skills/axiomcode/scripts/ax_grep.py | 1 + .../skills/axiomcode/scripts/axiomcode-impact | 13 ++++++++--- skills/axiomcode/SKILL.md | 1 + skills/axiomcode/reference/schema.md | 3 +++ .../same-file-is-not-a-dependent/case.json | 19 +++++++++++++++ .../src/Caller.cs | 6 +++++ .../src/Demo.csproj | 5 ++++ .../src/Wiring.cs | 23 +++++++++++++++++++ tests/enrich_lines.py | 11 +++++++++ tests/fastpath.py | 2 +- 13 files changed, 86 insertions(+), 7 deletions(-) create mode 100644 tests/cases/csharp/same-file-is-not-a-dependent/case.json create mode 100644 tests/cases/csharp/same-file-is-not-a-dependent/src/Caller.cs create mode 100644 tests/cases/csharp/same-file-is-not-a-dependent/src/Demo.csproj create mode 100644 tests/cases/csharp/same-file-is-not-a-dependent/src/Wiring.cs diff --git a/plugins/axiomcode/hooks/changes.py b/plugins/axiomcode/hooks/changes.py index ef48771ab..5a44f4da5 100644 --- a/plugins/axiomcode/hooks/changes.py +++ b/plugins/axiomcode/hooks/changes.py @@ -75,7 +75,7 @@ def impact(d): for d, j in results: hd = f" {d['kind']} {d['symbol']}" + (f" — {d['detail']}" if d.get('detail') else '') if not j: lines.append(hd + " (impact unavailable)"); continue - con = j.get('contract', []); dr = sorted(j.get('direct', []), key=lambda x: (rank.get(x['certainty'], 9), x['display'])); rc = j.get('reached', []); ts = j.get('tests', []) + con = j.get('contract', []); dr = sorted((x for x in j.get('direct', []) if x.get('certainty') != 'alongside'), key=lambda x: (rank.get(x['certainty'], 9), x['display'])); rc = j.get('reached', []); ts = j.get('tests', []) prod = [x for x in dr if x['role'] in ('produces', 'writes')]; reads = [x for x in dr if x['role'] in ('reads', 'uses')] # THE TIER TRAVELS WITH THE ROW OR IT IS NOT READ. The printed command labels every row [resolved] / # [by name] / [text]; this line dropped the label, so four rows of dataflow -- two of them reflective diff --git a/plugins/axiomcode/hooks/enrich.py b/plugins/axiomcode/hooks/enrich.py index 8972fcc82..a24f68361 100755 --- a/plugins/axiomcode/hooks/enrich.py +++ b/plugins/axiomcode/hooks/enrich.py @@ -195,13 +195,15 @@ def impact(d): for d, j in results: head = f" {d['kind']} {d['symbol']}" + (f" — {d['detail']}" if d.get('detail') else '') if not j: lines.append(head + " (impact unavailable)"); continue - con = j.get('contract', []); dr = j.get('direct', []); rc = j.get('reached', []); ts = j.get('tests', []) + # `alongside` (same file, same type; no call, no reference) is not a user: an answer from before it had its own list + # still carries it in `direct`, so it is dropped here too + con = j.get('contract', []); dr = [x for x in j.get('direct', []) if x.get('certainty') != 'alongside']; rc = j.get('reached', []); ts = j.get('tests', []) # ordered as ax_edges.DIRECT_ORDER and impact's CERT are: an edge the engine asserted outranks a # name or a text match, and neither a hand-off nor a truncated fan-out outranks a resolved call. rank = {'resolved': 0, 'one of a set': 1, 'registered': 2, 'capped set': 3, 'in scope': 4, 'by name': 5, 'text': 6} dr = sorted(dr, key=lambda x: (rank.get(x['certainty'], 9), x['display'])) prod = [x for x in dr if x['role'] in ('produces', 'writes')]; reads = [x for x in dr if x['role'] in ('reads', 'uses')] - def names(xs, k=4): return ', '.join(f"{x['display']} {x['at'].split('/')[-1]}" for x in xs[:k]) + (f" … +{len(xs) - k}" if len(xs) > k else '') + def names(xs, k=4): return ', '.join(f"[{x['certainty']}] {x['display']} {x['at'].split('/')[-1]}" for x in xs[:k]) + (f" … +{len(xs) - k}" if len(xs) > k else '') lines.append(head) if con and d['kind'] in ('signature', 'field', 'type', 'removed'): lines.append(f" must change with it ({len(con)}): " + ', '.join(f"{x['display']} ({x['why']})" for x in con[:4]) + (' …' if len(con) > 4 else '')) if prod: lines.append(f" produces / writes it ({len(prod)}): " + names(prod)) diff --git a/plugins/axiomcode/skills/axiomcode/SKILL.md b/plugins/axiomcode/skills/axiomcode/SKILL.md index c39f2fdf4..48e1c45a9 100644 --- a/plugins/axiomcode/skills/axiomcode/SKILL.md +++ b/plugins/axiomcode/skills/axiomcode/SKILL.md @@ -66,6 +66,7 @@ An answer's label is the **worst** rung on its route. Read it before acting on t | `[by key]` | joined through a registration **string** (route, signal, CLI command) — not an edge | | `[stubs it]` | a call written inside a mock's stub or verification (`when(m.f())`, `verify(m).f()`, `Setup(x => x.F())`, `Received().F()`): names it, runs none of it — never a test route, listed apart | | `[in scope]` · `[by name]` · `[text]` | same name in the owner's scope · same name elsewhere (may be another thing) · text only | +| `[alongside]` | declared in the same type or file — no call, no reference; its own section (`alongside` in `--json`), never a dependent | Below `[sound]` / `[one of a set]` the order is a tie-break, not a measured ranking. `[sound]` means the edges connect, not that a test exercises the change. diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py index f8607426d..5869ee6db 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py @@ -94,6 +94,7 @@ def more(k, n=1): rest[k] = rest.get(k, 0) + n for r in d.get('contract', []): 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', []): cert = r.get('certainty') or 'resolved' if cert == 'alongside': more('alongside (no call, no reference)'); continue diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index fe181f09c..097460996 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -2237,7 +2237,11 @@ def main(argv): # 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 # whatever the solver happened to emit — which is not the same between backends. - 'direct': [{'id': c, 'display': g.disp(c), 'role': role, 'why': why, 'also': direct_also.get((c, _grp(role)), []), 'reasons': direct_reasons.get((c, _grp(role)), []), 'certainty': cert, 'at': loc, 'sites': n, 'for': sorted(direct_for[(c, _grp(role))])} for c, role, why, cert, loc, n in sorted(D, key=lambda x: (CERT[x[3]], x[1], g.disp(x[0]), x[2], x[4], x[0]))], + # AN `alongside` ROW IS NOT A DEPENDENT, so it never shares `direct` or a role with one. It sat + # there as role 'uses', and a reader keyed on role or section counted a same-file sibling as a + # user: a check passed on it, then failed when the type moved to another file with no edge changed. + 'direct': [{'id': c, 'display': g.disp(c), 'role': role, 'why': why, 'also': direct_also.get((c, _grp(role)), []), 'reasons': direct_reasons.get((c, _grp(role)), []), 'certainty': cert, 'at': loc, 'sites': n, 'for': sorted(direct_for[(c, _grp(role))])} for c, role, why, cert, loc, n in sorted(D, key=lambda x: (CERT[x[3]], x[1], g.disp(x[0]), x[2], x[4], x[0])) if cert != 'alongside'], + 'alongside': [{'id': c, 'display': g.disp(c), 'role': 'co-located', 'why': why, 'reasons': direct_reasons.get((c, _grp(role)), []), 'certainty': cert, 'at': loc, 'for': sorted(direct_for[(c, _grp(role))])} for c, role, why, cert, loc, n in sorted(D, key=lambda x: (g.disp(x[0]), x[2], x[4], x[0])) if cert == 'alongside'], 'reached': [{'id': m, 'display': g.disp(m), 'hops': d, 'for': sorted(reach_from[m]), 'at': g.loc(m), 'test': bool(g.sym[m]['is_test'])} for m, d in sorted(reached.items(), key=lambda x: (x[1], g.disp(x[0]), g.loc(x[0]), x[0]))], 'tests': [{'id': m, 'display': g.disp(m), 'owner': g.sym[m]['owner'], 'name': g.sym[m]['name'], 'hops': d, 'via': g.disp(fx) if fx else None, 'at': g.loc(m), 'certainty': test_cert.get(m), 'chain': [g.disp(x) for x in chains.get(m, [])], **({'script': True, 'run': script_cmd(m)} if m in script_ids else {})} for m, (d, fx) in sorted(tests.items(), key=lambda kv: (kv[1][0], g.disp(kv[0]), g.loc(kv[0]), kv[0]))], @@ -2274,7 +2278,10 @@ def main(argv): # the hidden tail. Surest first, then named before anonymous, then the entries with the most sites anon = lambda c: g.disp(c).split('.')[-1].startswith('<') D.sort(key=lambda x: (CERT[x[3]], anon(x[0]), -(x[5] or 1), x[2], g.disp(x[0]))) - groups = [('produces or writes it', [x for x in D if x[1] in ('produces', 'writes')]), ('reads or uses it', [x for x in D if x[1] in ('reads', 'uses')])] + # `alongside` rows are their own section, never counted under `reads or uses it`: no call, no reference, so no dependent + groups = [('produces or writes it', [x for x in D if x[1] in ('produces', 'writes') and x[3] != 'alongside']), + ('reads or uses it', [x for x in D if x[1] in ('reads', 'uses') and x[3] != 'alongside']), + ('declared alongside it, not a dependent', [x for x in D if x[3] == 'alongside'])] # AN ENTRY POINT IS NOT "NOTHING". A route handler, a queue consumer or a CLI command is invoked BY THE # FRAMEWORK, which is exactly WHY no client call site reaches it. Printing only "nothing the graph can see" # for a live HTTP route reads as dead code and is the single most misleading line this command can emit @@ -2350,7 +2357,7 @@ def main(argv): # library base is the only signal `next:` states it as the condition it is (the `maybe` case below) unmod_own = [x for x in unmod if not (' on its type ' in x[1] or x[1].endswith('which the graph does not contain'))] unmod_sig = '; '.join(sig for _i, sig in unmod[:3]) + (f" (+{len(unmod) - 3} more)" if len(unmod) > 3 else '') - if not D: print("directly touches it: nothing the graph can see" + (" — the declaration is used only where it is declared, or through something the parser did not record" if not contract else '')) + if not [x for x in D if x[3] != 'alongside']: print("directly touches it: nothing the graph can see" + (" — the declaration is used only where it is declared, or through something the parser did not record" if not contract else '')) # a throws clause is a contract: say what adding one to this method would cost, and who already handles the ones it has n_callers = len({c for c, role, why, cert, loc, n in D if cert == 'resolved' and 'calls it' in why}) thrown = sorted({x[0] for x in res.get('target_throws', [])}) diff --git a/skills/axiomcode/SKILL.md b/skills/axiomcode/SKILL.md index ee14fea97..1570ee7de 100644 --- a/skills/axiomcode/SKILL.md +++ b/skills/axiomcode/SKILL.md @@ -66,6 +66,7 @@ An answer's label is the **worst** rung on its route. Read it before acting on t | `[by key]` | joined through a registration **string** (route, signal, CLI command) — not an edge | | `[stubs it]` | a call written inside a mock's stub or verification (`when(m.f())`, `verify(m).f()`, `Setup(x => x.F())`, `Received().F()`): names it, runs none of it — never a test route, listed apart | | `[in scope]` · `[by name]` · `[text]` | same name in the owner's scope · same name elsewhere (may be another thing) · text only | +| `[alongside]` | declared in the same type or file — no call, no reference; its own section (`alongside` in `--json`), never a dependent | Below `[sound]` / `[one of a set]` the order is a tie-break, not a measured ranking. `[sound]` means the edges connect, not that a test exercises the change. diff --git a/skills/axiomcode/reference/schema.md b/skills/axiomcode/reference/schema.md index b423e757c..09039f8fc 100644 --- a/skills/axiomcode/reference/schema.md +++ b/skills/axiomcode/reference/schema.md @@ -99,5 +99,8 @@ sqlite3 -readonly $G "SELECT c2, count(*) FROM ext_call_site_unresolved GROUP BY its `class Meta:` line. - **Names.** Python `type_refs` and `decorations` keep only the last dotted segment; the full text is in `ext_type_base_unresolved.c3` and `decorations.text`. String cells are CSV-escaped: match with `LIKE '%x%'`. + JavaScript `symbols`: a field (`this.x = …` in a constructor or constructor function, a class field) has its class as + `owner` (`Store.items`); a member with a computed key is named by the key as written (`Tagged.[Symbol.hasInstance]`); + an anonymous class expression takes the name it is bound to (`static Inner = class {…}` → `Outer.Inner`). - **`ext_*` tables** have positional columns `c0…cN`; `SELECT description FROM schema_tables WHERE name = ''` names them. diff --git a/tests/cases/csharp/same-file-is-not-a-dependent/case.json b/tests/cases/csharp/same-file-is-not-a-dependent/case.json new file mode 100644 index 000000000..ccea1353f --- /dev/null +++ b/tests/cases/csharp/same-file-is-not-a-dependent/case.json @@ -0,0 +1,19 @@ +{"lang": "csharp", "src": "src", + "checks": [ + {"why": "a type declared in the same file that never names the target is not a dependent: it is listed apart, and `reads or uses it` counts only the real caller", + "run": ["impact", "Handler.Apply"], + "want": ["reads or uses it (1 callable(s): 1 resolved):", "declared alongside it, not a dependent (", "[alongside] Registrar.Wire"], + "avoid": ["reads or uses it (4 callable(s)"]}, + {"why": "--json keeps it out of `direct` and gives it its own list and role, so a reader keyed on `direct` or `role` never counts it", + "run": ["impact", "Handler.Apply", "--json"], + "stdout_json": true, + "want": ["\"alongside\": [", "\"display\": \"Registrar.Wire\",\n \"role\": \"co-located\"", "\"display\": \"Caller.Run\",\n \"role\": \"uses\""], + "avoid": ["\"display\": \"Registrar.Wire\",\n \"role\": \"uses\""]}, + {"why": "a const's same-file neighbours are not its readers; the method that reads it stays one (control)", + "run": ["impact", "Keys.Key"], + "want": ["reads or uses it (1 callable(s): 1 in scope):", "Keys.Read src/Wiring.cs:20 — reads it", "[alongside] Note."], + "avoid": ["reads or uses it (4 callable(s)"]}, + {"why": "control: with only alongside rows, nothing directly touches it, and the answer says so", + "run": ["impact", "Registrar.Wire"], + "want": ["directly touches it: nothing the graph can see", "declared alongside it, not a dependent ("], + "avoid": ["reads or uses it ("]}]} diff --git a/tests/cases/csharp/same-file-is-not-a-dependent/src/Caller.cs b/tests/cases/csharp/same-file-is-not-a-dependent/src/Caller.cs new file mode 100644 index 000000000..839f25962 --- /dev/null +++ b/tests/cases/csharp/same-file-is-not-a-dependent/src/Caller.cs @@ -0,0 +1,6 @@ +namespace Demo; + +public static class Caller +{ + public static void Run() { Handler.Apply(new Ctx()); } +} diff --git a/tests/cases/csharp/same-file-is-not-a-dependent/src/Demo.csproj b/tests/cases/csharp/same-file-is-not-a-dependent/src/Demo.csproj new file mode 100644 index 000000000..ec2cce143 --- /dev/null +++ b/tests/cases/csharp/same-file-is-not-a-dependent/src/Demo.csproj @@ -0,0 +1,5 @@ + + + net8.0 + + diff --git a/tests/cases/csharp/same-file-is-not-a-dependent/src/Wiring.cs b/tests/cases/csharp/same-file-is-not-a-dependent/src/Wiring.cs new file mode 100644 index 000000000..3f7d7d773 --- /dev/null +++ b/tests/cases/csharp/same-file-is-not-a-dependent/src/Wiring.cs @@ -0,0 +1,23 @@ +namespace Demo; + +public class Builder { } +public class Ctx { } + +// two types in one file; neither names the other +public static class Registrar +{ + public static void Wire(Builder b) { } +} + +public static class Handler +{ + public static void Apply(Ctx c) { } +} + +public static class Keys +{ + public const string Key = "k"; + public static string Read() => Key; +} + +public record Note(string Text); diff --git a/tests/enrich_lines.py b/tests/enrich_lines.py index f6a49aaf5..22bc568a2 100644 --- a/tests/enrich_lines.py +++ b/tests/enrich_lines.py @@ -56,6 +56,10 @@ # two overloads in one file: one name to the reader, printed once P + 'Pricer.java': ('package app.orders;\n\npublic class Pricer {\n public int quoteAll(int n) { return n; }\n\n' ' public int quoteAll(String s) { return quoteAll(s.length()); }\n}\n'), + # a constant with one reader, a sibling that does not read it, and a second class in the same file + P + 'Limits.java': ('package app.orders;\n\npublic class Limits {\n public static final int MAX_ITEMS = 3;\n\n' + ' public int cap() { return MAX_ITEMS; }\n\n public int floor() { return 1; }\n}\n\n' + 'class Spare {\n int spareCount() { return 2; }\n}\n'), } # two test stubs in modules whose paths sort before core/, with the same file name and line for m in ('json', 'xml'): @@ -175,6 +179,13 @@ def edit(session, text, hook='enrich.py', event='PostToolUse'): out = fire(repo, 'u1', '', {}, 'changes.py', 'UserPromptSubmit') check('the prompt-time report of a body edit is the same one line', 'body edit of OrderStore.findById' in out and 'reads / uses it' not in out, out) open(f, 'w').write(orig) + # a declaration beside it in the file (a sibling, another class) is not a reader: `reads / uses it` counts readers only + f = os.path.join(repo, P + 'Limits.java') + orig = open(f).read() + out = edit('e5', orig.replace(' public static final int MAX_ITEMS = 3;\n\n', '')) + check('a removed constant lists its reader and not the declarations beside it', + 'reads / uses it (1): ' in out and 'Limits.cap' in out and 'Limits.floor' not in out and 'Spare.spareCount' not in out, out) + open(f, 'w').write(orig) # ── what a Read of a script says: a call in the text read is not an edge the text does not show ───────────── PY = { diff --git a/tests/fastpath.py b/tests/fastpath.py index a19f8db96..8b016a234 100644 --- a/tests/fastpath.py +++ b/tests/fastpath.py @@ -99,7 +99,7 @@ def main(argv=None): if a[k] != b[k]: print(f" {k}: fast={a[k]} rules={b[k]}") bad += 1 else: - al = sorted({x['display'] for x in j.get('direct', []) if x.get('certainty') == ALONG}) + al = sorted({x['display'] for x in j.get('alongside', [])}) print(f"ok {target!r}: {len(a['direct'])} direct, {a['reached']} reached — identical to the rules" + (f" (the rules also list {len(al)} `alongside` row(s), not compared: {', '.join(al)})" if al else '')) finally: