From 67ec0bca18ef2d3e268afaddc7cadcfc5ed32ed7 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 01:10:35 -0700 Subject: [PATCH] plugin: enrich lines say why a count is 0, and an edit gets one line of tests (#1507, #1546, #1604) Read/Grep enrichment: - A method with no resolved caller no longer prints a bare "<- 0". It prints "<- entry (http)" from entry_points, "<- ? framework (@X)" from a non-inert decoration on the method, "<- 0 resolved, N by name" from unresolved call sites that write its name, "? framework (overrides a library method)" for an @Override with no base in the repo, and "? framework (@X on Owner)" from a class annotation. With no signal it still reads "<- 0". - The Grep caller preview names production callers before test callers and counts the tests apart (#1507). - A name declared several times lists the base first with its override count, production before tests, then by path; same-named files print with the path suffix that tells them apart, and the rest are counted (#1546). - A shell grep after a pipe, or over files no graph indexes, adds nothing; a constant-shaped word (FAIL, DONE) is not looked up; a shell grep matches whole names; the Grep tool's prefix match is case-sensitive (GLOB) instead of LIKE (#1604). - A Read whose callables have no edges no longer prints a header over no lines. Edit hooks (enrich.py and changes.py): - An edit that changes no declaration prints nothing. - A declaration already reported this session is not reported again on the PostToolUse Edit path. - A body-only change prints one line: the tests that reach it and the command test-impact would print for them. - Impact is asked with the target as `changed` prints it; the package-qualified target was refused, so every Java signature edit said "(impact unavailable)". tests/enrich_lines.py: 28 checks with near-miss controls; 21 fail on the base branch, all controls hold there. --- plugins/axiomcode/hooks/_graphline.py | 142 +++++++++++++++ plugins/axiomcode/hooks/changes.py | 27 +-- plugins/axiomcode/hooks/enrich.py | 111 +++++++++--- .../skills/axiomcode/scripts/graph_sql.py | 5 +- tests/README.md | 5 + tests/enrich_lines.py | 170 ++++++++++++++++++ 6 files changed, 419 insertions(+), 41 deletions(-) create mode 100644 plugins/axiomcode/hooks/_graphline.py create mode 100644 tests/enrich_lines.py diff --git a/plugins/axiomcode/hooks/_graphline.py b/plugins/axiomcode/hooks/_graphline.py new file mode 100644 index 000000000..f5b58f072 --- /dev/null +++ b/plugins/axiomcode/hooks/_graphline.py @@ -0,0 +1,142 @@ +"""What the enrich and changes hooks print about one declaration, where a bare number would mislead. + + zero_label a method with no resolved caller. Of 323 caller counts the Read / Grep hooks printed in headless + sessions, 209 were `← 0`, and most of those were methods a framework calls: a route handler, an + `@app.before_request`, a scheduled job, a library override. The graph stores why for most of them, + so the line says it: `entry (http)`, `0 resolved, 3 by name`, `? framework (@Scheduled)`. A method + with no signal at all still reads `0`. + body_line an edit that changed only bodies breaks no caller; the one thing worth saying is which tests reach + it and how to run them. The block it replaces listed 10-42 readers, and 28 of 30 went unused. + +Every lookup is one indexed query per declaration (these run on every Read, Grep and Edit).""" +import importlib.machinery, importlib.util, os, sqlite3 + +# decorations that say nothing about who calls the method: compiler hints, language plumbing, nullness +INERT = {'Override', 'SuppressWarnings', 'Deprecated', 'Serial', 'SafeVarargs', 'FunctionalInterface', 'Nullable', + 'NonNull', 'NotNull', 'Nonnull', 'CheckReturnValue', 'VisibleForTesting', 'Generated', 'staticmethod', + 'classmethod', 'property', 'abstractmethod', 'cached_property', 'functools.wraps', 'wraps', 'override', + 'overload', 'typing.override', 'typing.overload', 'CallerMemberName', 'Obsolete', 'MethodImpl', + 'DebuggerStepThrough', 'Pure', 'Data', 'Getter', 'Setter', 'Value', 'Builder', 'ToString', + 'EqualsAndHashCode', 'NoArgsConstructor', 'AllArgsConstructor', 'RequiredArgsConstructor', 'Slf4j'} + + +def _has(con, t): + return con.execute("SELECT 1 FROM sqlite_master WHERE name = ?", (t,)).fetchone() is not None + + +def _deco(con, owner_id): + """the first decoration on a declaration that could mean a framework calls it, as written (`@app.before_request`)""" + for name, text in con.execute("SELECT name, text FROM decorations WHERE owner_id = ? ORDER BY line", (owner_id,)): + if (name or '') in INERT or (name or '').rsplit('.', 1)[-1] in INERT: continue + t = (text or '').split('(')[0].strip() or '@' + (name or '') + if t.startswith('[') and not t.endswith(']'): t += ']' # a C# attribute, `[HttpGet("x")]` + return t[:48] + return None + + +def zero_label(con, mid, name, is_test=0): + """what `← 0` means for method `mid`, from the strongest signal the graph holds: + entry () the runtime invokes it: a route, a test, main, a scheduled job, a listener + ? framework (@X) a decoration on it that is not a compiler hint + 0 resolved, N by name N call sites write its name on a receiver the engine could not type + ? framework (overrides a library method) @Override with no base in this repository + ? framework (extends B) its class derives from a base outside the graph (Python) + ? framework (@X on Owner) a decoration on its class + 0 none of these: nothing in this graph calls it""" + try: + if _has(con, 'entry_points'): + r = con.execute("SELECT reason FROM entry_points WHERE method_id = ? LIMIT 1", (mid,)).fetchone() + if r: return f"entry ({r[0]})" + if is_test: return "entry (test)" + deco = _deco(con, mid) if _has(con, 'decorations') else None + if deco: return f"? framework ({deco})" + if name and _has(con, 'unresolved_sites'): + n = con.execute("""SELECT count(*) FROM call_sites cs JOIN unresolved_sites u ON u.call_site_id = cs.id + WHERE cs.callee_name = ? AND cs.kind NOT IN ('new', 'anon_new', 'DECORATOR_APPLICATION')""", + (name,)).fetchone()[0] + if n: return f"0 resolved, {n} by name" + owner = con.execute("SELECT owner_type_id FROM methods WHERE id = ?", (mid,)).fetchone() if _has(con, 'methods') else None + owner = owner[0] if owner else None + if _has(con, 'decorations') and con.execute( + "SELECT 1 FROM decorations WHERE owner_id = ? AND name = 'Override' LIMIT 1", (mid,)).fetchone() \ + and not con.execute("""SELECT 1 FROM overrides o JOIN methods b ON b.id = o.method_id + WHERE o.overriding_method_id = ? AND b.provenance = 'client' LIMIT 1""", (mid,)).fetchone(): + return "? framework (overrides a library method)" + if owner and _has(con, 'ext_type_base_unresolved'): + r = con.execute("SELECT c3 FROM ext_type_base_unresolved WHERE c1 = ? LIMIT 1", (owner,)).fetchone() + if r and r[0]: return f"? framework (extends {r[0]})" + if owner and _has(con, 'decorations'): + d = _deco(con, owner) + if d: + on = con.execute("SELECT name FROM types WHERE id = ?", (owner,)).fetchone() if _has(con, 'types') else None + return f"? framework ({d}" + (f" on {on[0]}" if on else '') + ")" + except sqlite3.Error: + pass + return "0" + + +def distinct_paths(files): + """the shortest path suffix that tells these files apart: two `StubConverterFactory.java` in different modules print + as `json/StubConverterFactory.java` and `xml/StubConverterFactory.java`, never as the same line twice""" + parts = [f.split('/') for f in files] + out = [] + for i, p in enumerate(parts): + k = 1 + while k < len(p) and any(j != i and q[-k:] == p[-k:] for j, q in enumerate(parts)): k += 1 + out.append('/'.join(p[-k:])) + return out + + +_TI = None +def _command_for(lang, files, classes): + """the runnable command `axiomcode test-impact` prints, from the same function""" + global _TI + if _TI is None: + p = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'skills', 'axiomcode', 'scripts', 'axiomcode-test-impact') + try: + ld = importlib.machinery.SourceFileLoader('ax_test_impact', p) + m = importlib.util.module_from_spec(importlib.util.spec_from_loader('ax_test_impact', ld)); ld.exec_module(m) + _TI = m.command_for + except Exception: + _TI = lambda *a: None + try: return _TI(lang, files, classes) + except Exception: return None + + +LANG = {'.java': 'java', '.py': 'python', '.ts': 'typescript', '.tsx': 'typescript', '.js': 'javascript', + '.mjs': 'javascript', '.cjs': 'javascript', '.jsx': 'javascript', '.cs': 'csharp'} +SHOWN = 6 + + +def body_line(db, results): + """ONE line for an edit that changed only bodies: which declarations, how many tests reach them, how to run those. + `results` is [(changed-declaration, impact-json)], the same pairs the blast-radius block is built from.""" + names = [d['symbol'] for d, _ in results] + ids, owners, files = set(), set(), set() + for d, j in results: + ids |= set((j or {}).get('test_ids') or []) + for t in (j or {}).get('tests') or []: # the rules' answer carries names, not ids + if not (j or {}).get('_sql') and t.get('owner'): owners.add(t['owner']) + if not (j or {}).get('_sql') and t.get('at'): files.add(t['at'].split(':')[0]) + n = len(ids) or sum(len((j or {}).get('tests') or []) for _, j in results) + if ids: + try: + con = sqlite3.connect(f'file:{db}?mode=ro', uri=True) + idl = sorted(ids) + for i in range(0, len(idl), 400): + ch = idl[i:i + 400] + for o, f in con.execute(f"SELECT owner, file FROM symbols WHERE id IN ({','.join('?' * len(ch))})", ch): + if o: owners.add(o) + if f: files.add(f) + con.close() + except sqlite3.Error: + pass + what = ', '.join(names[:3]) + (f" +{len(names) - 3}" if len(names) > 3 else '') + if not n: + return f"graph: body edit of {what}: no test reaches it through the graph (a lower bound)" + lang = LANG.get(os.path.splitext(results[0][0].get('file', ''))[1], '') + fl, cl = sorted(files), sorted(owners) + cmd = _command_for(lang, fl[:SHOWN], cl[:SHOWN]) + more = (len({c.split('.')[-1] for c in cl}) if lang in ('java', 'csharp') and cl else len(fl)) - SHOWN + tail = (f"; run: {cmd}" + (f" (+{more} more: axiomcode test-impact)" if more > 0 else '')) if cmd else "; axiomcode test-impact gives the command" + return f"graph: body edit of {what}: {n} test(s) reach it{tail}" diff --git a/plugins/axiomcode/hooks/changes.py b/plugins/axiomcode/hooks/changes.py index f19922889..e84b06a97 100644 --- a/plugins/axiomcode/hooks/changes.py +++ b/plugins/axiomcode/hooks/changes.py @@ -17,7 +17,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'skills', 'axiomcode', 'scripts')) import graph_sql sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -import _host +import _host, _graphline ev = _host.read(); event = ev.get('hook_event_name', ''); tool = ev.get('tool_name', ''); inp = ev.get('tool_input', {}) or {}; cwd = ev.get('cwd') or os.getcwd() if not os.path.exists(os.path.join(cwd, '.axiomcode', 'out', 'graph.sqlite')): sys.exit(0) @@ -43,7 +43,10 @@ def changed(args, timeout=12): except Exception: return {} def summarize(decls, head, contract_kinds=('signature', 'field', 'type', 'removed')): - """the blast radius of up to three changed declarations, a few lines each""" + """the blast radius of up to three changed declarations, a few lines each; `head` is formatted with {n}, how many. + A BODY-ONLY change breaks no caller, so it gets no blast radius: one line of the tests that reach it and the command + that runs them (_graphline.body_line), after the declarations that can break something.""" + body = [d for d in decls if d['kind'] == 'body']; decls = [d for d in decls if d['kind'] != 'body'] def impact(d): """SQL first. The Datalog run this replaced was a median 6.94 s on a 1.2M-LOC bundle, p90 23.8 s, and 17 of 38 randomly sampled methods blew the 14 s timeout below — so on roughly half of real edits the hook printed @@ -52,13 +55,14 @@ def impact(d): It returns None for what it does not cover (a constructor, whose callers are instantiations rather than call edges); that falls through to impact.dl, which is still right for those.""" try: - j = graph_sql.impact_shaped(cwd, d['target']) + j = graph_sql.impact_shaped(cwd, d.get('shown_target') or d['target']) if j is not None: return d, j except Exception: pass - try: return d, json.loads(subprocess.run([sys.executable, os.path.join(SCR, 'axiomcode-impact'), d['target'], cwd, '--json', '--depth', '12'] + (['--kind', d['target_kind']] if d.get('target_kind') and d['target_kind'] != 'param' and '(' not in d['target'] else []), capture_output=True, text=True, timeout=14).stdout or '{}') + try: return d, json.loads(subprocess.run([sys.executable, os.path.join(SCR, 'axiomcode-impact'), d.get('shown_target') or d['target'], cwd, '--json', '--depth', '12'] + (['--kind', d['target_kind']] if d.get('target_kind') and d['target_kind'] != 'param' and '(' not in d['target'] else []), capture_output=True, text=True, timeout=14).stdout or '{}') except Exception: return d, {} - with concurrent.futures.ThreadPoolExecutor(max_workers=3) as ex: results = list(ex.map(impact, decls[:3])) - lines = [head] + with concurrent.futures.ThreadPoolExecutor(max_workers=3) as ex: + results = list(ex.map(impact, decls[:3])); bodies = list(ex.map(impact, body[:3])) + lines = [head.format(n=len(decls))] if decls else [] # 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} @@ -98,7 +102,7 @@ def impact(d): if reads: lines.append(f" reads / uses it ({len(reads)}): " + names(reads) if not j.get('_sql') else f" reads / uses it — resolved callers: " + names(reads) - + f" (the fast path; `axiomcode impact {d['target']}` adds the by-name, in-scope and text layers)") + + f" (the fast path; `axiomcode impact {d.get('shown_target') or d['target']}` adds the by-name, in-scope and text layers)") # WHICH SIDE ANSWERED, in one word. The two paths give different answers by design — the fast path reads # call_edges and the rules add the by-name, in-scope and text layers — so a count nobody can attribute is a # count nobody can check. This cost a whole re-derivation once: three declarations reported 0 reached and @@ -106,6 +110,9 @@ def impact(d): # was the fast path answering, the rules answering, or the CLI having given up. 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: + lines.append(_graphline.body_line(os.path.join(os.environ.get('AXIOMCODE_GRAPH') or os.path.join(cwd, '.axiomcode'), 'out', 'graph.sqlite'), + bodies + [(d, {}) for d in body[3:]])) return lines def key(d): return f"{d['file']}:{d['symbol']}:{d['kind']}:{d.get('detail', '')}" @@ -125,7 +132,7 @@ def key(d): return f"{d['file']}:{d['symbol']}:{d['kind']}:{d.get('detail', '')} j = changed(['--old', fp, '--new', tmp, '--file', rel]); os.unlink(tmp) risky = [d for d in j.get('changed', []) if d.get('target') and d['kind'] in ('signature', 'field', 'type', 'removed')] if risky: - lines = summarize(risky, f"graph: this edit is about to change {len(risky)} declaration(s) in {rel} in a way that reaches callers — before it lands:") + lines = summarize(risky, "graph: this edit is about to change {n} declaration(s) in " + rel.replace('{', '{{').replace('}', '}}') + " in a way that reaches callers — before it lands:") st = load_state(); st['reported'] = list(dict.fromkeys(st.get('reported', []) + [key(d) for d in risky])); save_state(st) elif event in ('PostToolUse', 'UserPromptSubmit'): # after the fact, an edit is measured against the baseline graph, where what it removed still has its callers; @@ -145,14 +152,14 @@ def key(d): return f"{d['file']}:{d['symbol']}:{d['kind']}:{d.get('detail', '')} st = load_state(); seen = set(st.get('reported', [])) new = [d for d in j.get('changed', []) if d.get('target') and key(d) not in seen and not TEST.search(d['file'])] if new: - lines = summarize(new, f"graph: after that command, {len(new)} declaration(s) changed in the working tree (against the graph's commit {(j.get('built_at') or '')[:10]}) —") + lines = summarize(new, f"graph: after that command, {{n}} declaration(s) changed in the working tree (against the graph's commit {(j.get('built_at') or '')[:10]}) —") st['reported'] = list(seen | {key(d) for d in new}); save_state(st) elif event == 'UserPromptSubmit': j = changed([], timeout=18) st = load_state(); seen = set(st.get('reported', [])) new = [d for d in j.get('changed', []) if d.get('target') and key(d) not in seen and not TEST.search(d['file'])] if new: - lines = summarize(new, f"graph: {len(new)} declaration(s) changed in the working tree since the graph's commit {(j.get('built_at') or '')[:10]} and were not reported yet —") + lines = summarize(new, f"graph: {{n}} declaration(s) changed in the working tree since the graph's commit {(j.get('built_at') or '')[:10]} and were not reported yet —") st['reported'] = list(seen | {key(d) for d in new}); save_state(st) try: with open(os.path.join(cwd, '.axiomcode', 'hooks.jsonl'), 'a') as f: f.write(json.dumps({'event': event, 'tool': tool, 'lines': len(lines), 'chars': sum(len(l) for l in lines), 'input': {k: v for k, v in inp.items() if k in ('file_path', 'command', 'old_string', 'new_string')}, 'text': '\n'.join(lines)}) + '\n') diff --git a/plugins/axiomcode/hooks/enrich.py b/plugins/axiomcode/hooks/enrich.py index e5b9b30fc..5f8597af8 100755 --- a/plugins/axiomcode/hooks/enrich.py +++ b/plugins/axiomcode/hooks/enrich.py @@ -13,7 +13,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'skills', 'axiomcode', 'scripts')) import graph_sql, ax_contract as _ax sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -import _host +import _host, _graphline ev = _host.read(); tool = ev.get('tool_name', ''); inp = ev.get('tool_input', {}) or {}; cwd = ev.get('cwd') or os.getcwd() def rel_of(fp): @@ -55,13 +55,6 @@ def task_hits(display): out = [t for t in TASK if t in toks or any(x.startswith(t) and len(t) >= 4 for x in toks)] return out[:2] -def edges(mid, sid): - up = q("SELECT DISTINCT cr.display d FROM call_edges e JOIN symbols cr ON cr.id = e.caller_id WHERE e.callee_method_id = ? LIMIT 40", mid) - dn = q("SELECT DISTINCT ce.display d FROM call_edges e JOIN symbols ce ON ce.method_id = e.callee_method_id WHERE e.caller_id = ? AND e.callee_provenance = 'client' LIMIT 40", sid) - un = q("SELECT count(*) n FROM unresolved_sites WHERE caller_id = ?", sid)[0]['n'] - s = f"← {len(up)}" + (" (" + ', '.join(r['d'].split('.')[-1] for r in up[:2]) + (', …' if len(up) > 2 else '') + ")" if up else '') + f" → {len(dn)}" - return s + (f" ? {un}" if un else '') - # where the agent IS: the callables it read most recently (per session, last 6 reads). A later grep for a common name is # read against them — the `close` that the method you were just reading calls is the one you mean STATE = os.path.join(cwd, '.axiomcode', f"hooks-state-{ev.get('session_id', 'x')}.json") @@ -76,6 +69,7 @@ def save_state(st): # as `file:line`; a Read of one of them right after would get the same callers and callees again as a `graph:` block. Those # declarations are marked annotated, so a later block carries only what the answer did not. Nothing is emitted here. _cmd = str(inp.get('command', '')) if tool == 'Bash' else '' +_from_shell = False # a grep run through Bash: matched as whole names only if tool.startswith('mcp__plugin_axiomcode_') or re.match(r'\s*(?:\S*/)?axiomcode(?:-\w+)?\s+(context|path|impact|changed|test-impact)\b', _cmd): text = json.dumps(ev.get('tool_response', '')) locs = set(re.findall(r'([\w./-]+\.\w+):(\d+)', text)) @@ -103,7 +97,14 @@ def outside(p): except ValueError: rest = [] where = [a for a in rest if not a.startswith('-') and ('/' in a or a.startswith(('~', '.')))] if (cds and outside(cds[-1])) or any(outside(a) for a in where): sys.exit(0) - if m: tool = 'Grep'; inp = {'pattern': m.group(3)} + # A GREP THAT IS NOT A CODE SEARCH SAYS NOTHING ABOUT THIS GRAPH (#1604). After a `|` it filters another command's + # output (`npm test | grep -E 'FAIL|ok'` named `Result.fail` and a test helper `fail`); over files no graph + # indexes (a log, a shell script, YAML) it names whatever shares a word with the pattern. + seps = re.findall(r'\|\||&&|;|\n|\|', c[:m.start()]) + if seps and seps[-1] == '|': sys.exit(0) + files = [a for a in rest if not a.startswith('-')] + if files and all(os.path.splitext(a)[1] and os.path.splitext(a)[1].lower() not in _graphline.LANG for a in files): sys.exit(0) + if m: tool = 'Grep'; inp = {'pattern': m.group(3)}; _from_shell = True else: m = re.search(r"sed -n '?(\d+),(\d+)p'? (\S+)", c) or re.search(r'\bcat\s+(\S+\.(?:java|ts|tsx|js|py))', c) if m and m.re.groups == 3: tool = 'Read'; inp = {'file_path': os.path.join(cwd, m.group(3)) if not m.group(3).startswith('/') else m.group(3), 'offset': int(m.group(1)), 'limit': int(m.group(2)) - int(m.group(1)) + 1} @@ -154,22 +155,33 @@ def context_ids(): try: ch = json.loads(subprocess.run([sys.executable, os.path.join(SCR, 'axiomcode-changed'), cwd, rel, '--json'], capture_output=True, text=True, timeout=10).stdout or '{}') except Exception: ch = {} decls = [d for d in ch.get('changed', []) if d.get('target')] - if not decls and not ch.get('notes'): sys.exit(0) - st = load_state(); st['reported'] = list(dict.fromkeys(st.get('reported', []) + [f"{d['file']}:{d['symbol']}:{d['kind']}:{d.get('detail', '')}" for d in decls])); save_state(st) # once per session (changes.py reads this) + # AN EDIT THAT CHANGED NO DECLARATION SAYS NOTHING. It printed "this edit changed 0 declaration(s) -- added: 1 new + # line(s)", which restates the edit the agent just made; 28 of 30 of these blocks went unused. + # A DECLARATION IS REPORTED ONCE A SESSION, on this path as on changes.py's: a signature the PreToolUse hook already + # reported before the edit landed, or a body edited a second time, is not reported again. + key = lambda d: f"{d['file']}:{d['symbol']}:{d['kind']}:{d.get('detail', '')}" + st = load_state(); done = set(st.get('reported', [])) + decls = [d for d in decls if key(d) not in done] + if not decls: sys.exit(0) + st['reported'] = list(dict.fromkeys(st.get('reported', []) + [key(d) for d in decls])); save_state(st) # once per session (changes.py reads this) + # A BODY EDIT BREAKS NO CALLER, so its readers are not a blast radius: 10-42 `reads / uses it` rows nobody could act on. + # What it is worth is the tests that reach it and the command that runs them, on one line. + body = [d for d in decls if d['kind'] == 'body']; decls = [d for d in decls if d['kind'] != 'body'] def impact(d): """SQL first, as changes.py does — the second call site, and the last Datalog dependency in the hooks. Shelling to axiomcode-impact cost a median 6.94 s on a 1.23M-LOC bundle and blew this timeout=14 on 19 of 40 sampled methods; it also carries #833, which kills that script at import wherever importlib.machinery is not incidentally bound.""" try: - j = graph_sql.impact_shaped(cwd, d['target']) + j = graph_sql.impact_shaped(cwd, d.get('shown_target') or d['target']) if j is not None: return d, j except Exception: pass - try: return d, json.loads(subprocess.run([sys.executable, os.path.join(SCR, 'axiomcode-impact'), d['target'], cwd, '--json', '--depth', '12'] + (['--kind', d['target_kind']] if d.get('target_kind') and d['target_kind'] != 'param' and '(' not in d['target'] else []), capture_output=True, text=True, timeout=14).stdout or '{}') + try: return d, json.loads(subprocess.run([sys.executable, os.path.join(SCR, 'axiomcode-impact'), d.get('shown_target') or d['target'], cwd, '--json', '--depth', '12'] + (['--kind', d['target_kind']] if d.get('target_kind') and d['target_kind'] != 'param' and '(' not in d['target'] else []), capture_output=True, text=True, timeout=14).stdout or '{}') except Exception: return d, {} - with concurrent.futures.ThreadPoolExecutor(max_workers=3) as ex: results = list(ex.map(impact, decls[:3])) + with concurrent.futures.ThreadPoolExecutor(max_workers=3) as ex: + results = list(ex.map(impact, decls[:3])); bodies = list(ex.map(impact, body[:3])) base = (ch.get('built_at') or '')[:10] - lines.append(f"graph: this edit changed {len(decls)} declaration(s) in {rel}" + (f" (against the graph's commit {base})" if base else '') + " —") + if decls: lines.append(f"graph: this edit changed {len(decls)} declaration(s) in {rel}" + (f" (against the graph's commit {base})" if base else '') + " —") 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 @@ -191,7 +203,7 @@ def names(xs, k=4): return ', '.join(f"{x['display']} {x['at'].split('/')[-1]}" if reads: lines.append(f" reads / uses it ({len(reads)}): " + names(reads) if not j.get('_sql') else f" reads / uses it — resolved callers: " + names(reads) - + f" (the fast path; `axiomcode impact {d['target']}` adds the by-name, in-scope and text layers)") + + f" (the fast path; `axiomcode impact {d.get('shown_target') or d['target']}` adds the by-name, in-scope and text layers)") # `reached` is a LIST OF PLACEHOLDERS from the SQL shim (graph_sql.impact_shaped fills it with None, # deliberately, because both hooks only take len() of it — resolving a location for rows nobody prints cost # 5.7 s against 1.7 s on a wide target). Iterating it and calling .get() therefore raised AttributeError and @@ -205,7 +217,11 @@ def names(xs, k=4): return ', '.join(f"{x['display']} {x['at'].split('/')[-1]}" # was the fast path answering, the rules answering, or the CLI having given up. 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 changed declaration(s): axiomcode changed --impact") - for n in ch.get('notes', [])[:2]: lines.append(f" added: {n}") + if decls: + for n in ch.get('notes', [])[:2]: lines.append(f" added: {n}") + if bodies: + lines.append(_graphline.body_line(os.path.join(os.environ.get('AXIOMCODE_GRAPH') or os.path.join(cwd, '.axiomcode'), 'out', 'graph.sqlite'), + bodies + [(d, {}) for d in body[3:]])) elif tool == 'Read': fp = str(inp.get('file_path', '')); rel = rel_of(fp) a = int(inp.get('offset') or 1); b = a + int(inp.get('limit') or 100000) @@ -235,8 +251,9 @@ def names(xs, k=4): return ', '.join(f"{x['display']} {x['at'].split('/')[-1]}" lo, hi = rows[0]['line'], rows[-1]['end_line'] or b # the lines the text actually covers mids = [r['method_id'] for r in rows]; ids = [r['id'] for r in rows]; ph = ','.join('?' * len(rows)) def visible(f, ln): return f == rel and lo <= ln <= hi # the other end is in the text the model just read - up = collections.defaultdict(list) - for e in q(f"SELECT DISTINCT e.callee_method_id m, cr.id, cr.display d, cr.file f, cr.line ln FROM call_edges e JOIN symbols cr ON cr.id = e.caller_id WHERE e.callee_method_id IN ({ph})", *mids): + up = collections.defaultdict(list); anyup = set() # anyup: has a caller at all, shown or not + for e in q(f"SELECT DISTINCT e.callee_method_id m, cr.id, cr.display d, cr.file f, cr.line ln FROM call_edges e JOIN symbols cr ON cr.id = e.caller_id WHERE e.callee_method_id IN ({ph}) ORDER BY cr.is_test, cr.display", *mids): + anyup.add(e['m']) if not visible(e['f'], e['ln']) and e['d'] not in {x['d'] for x in up[e['m']]}: up[e['m']].append(e) # overloads of one caller are one name dn = collections.defaultdict(list) for e in q(f"SELECT DISTINCT e.caller_id c, ce.id, ce.display d, ce.file f, ce.line ln FROM call_edges e JOIN symbols ce ON ce.method_id = e.callee_method_id WHERE e.caller_id IN ({ph}) AND e.callee_provenance = 'client'", *ids): @@ -262,18 +279,25 @@ def visible(f, ln): return f == rel and lo <= ln <= hi # the # A DECLARATION IS ANNOTATED ONCE A SESSION, however its lines are reached again: the same range, an overlapping # one, the whole file after a range of it, or the same file through another spelling of its path. A key on the # read's own arguments caught only the first of those. - had = bool(info); info = [x for x in info if x['r']['id'] not in done] + info = [x for x in info if x['r']['id'] not in done] block_ids = [x['r']['id'] for x in info] # an edge into a file the agent has not opened is what a read cannot show it; one into a file it has read is # something it may already have seen from the other end novel = any(y['f'] not in opened for x in info for y in x['up'] + x['dn']) or any(x['ovo'] for x in info) short = lambda d: d.split('.')[-1] if d.count('.') > 1 else d def nm(y): return y['d'] + (f" L{y['ln']}" if y['f'] == rel else '') # a same-file end outside the range: say where + # a caller count of 0 says WHY where the graph knows (#1507 cluster): `←entry (http)`, `←0 resolved, 2 by name`, + # `←? framework (@Scheduled)`. A framework-called method printed as `←0` read as dead code + def ups(x): + if x['up'] or x['r']['method_id'] in anyup: return str(len(x['up'])) + if 'zl' not in x: x['zl'] = _graphline.zero_label(con, x['r']['method_id'], x['r']['display'].rsplit('.', 1)[-1]) + return x['zl'] def line(x): r = x['r']; parts = [] if x['su']: parts.append("← " + ', '.join(f"{nm(y)} ★" for y in x['su'][:2]) + (f", +{len(x['up']) - min(2, len(x['su']))}" if len(x['up']) > min(2, len(x['su'])) else '')) elif 1 <= len(x['up']) <= 3: parts.append("← " + ', '.join(nm(y) for y in x['up'])) elif x['up']: parts.append(f"←{len(x['up'])}") + elif x['r']['method_id'] not in anyup and ups(x) != '0': parts.append(f"←{ups(x)}") if x['ovi'] or x['ovo']: parts.append("→ dispatch: " + ', '.join(filter(None, [f"{x['ovi']} override(s) in this file" if x['ovi'] else '', f"{x['ovo']} elsewhere" if x['ovo'] else '']))) if x['sd']: parts.append(f"→ {', '.join(f'{nm(y)} ★' for y in x['sd'][:2])}" + (f", +{len(x['dn']) - min(2, len(x['sd']))}" if len(x['dn']) > min(2, len(x['sd'])) else '')) elif x['dn']: parts.append(f"→{len(x['dn'])}" + (" " + ', '.join(nm(y) for y in x['dn'][:2]) if len(x['dn']) <= 2 else '')) @@ -290,12 +314,12 @@ def line(x): k = tuple(sorted({y['id'] for y in x['star']})) if seen[k] < 2: picked.append(x); seen[k] += 1 few = [x for x in info if x not in picked and (1 <= len(x['up']) <= 3 or x['ovi'] or x['ovo'])] - if had and not info: rows = [] # everything here was said before + if not info: rows = [] # everything here was said before, or nothing here has an edge: no header over no lines if rows: lines.append(f"graph: {os.path.basename(rel)}:{lo}-{hi} — {len(rows)} callable(s); edges the text does not show (cross-file, outside the range, overrides, unresolved)" + (" ★ = what you read before" if picked else '') + ":" + stale) shown = (picked + few)[:5] if rows else [] for x in shown: lines.append(line(x)) left = [x for x in info if x not in shown] if rows else [] - if left: lines.append(" " + ('+%d more: ' % len(left)) + ', '.join(f"{short(x['r']['display'])} ←{len(x['up'])}" + (f" →{len(x['dn'])}" if x['dn'] and not x['up'] else '') + (f" ?{x['un']}" if x['un'] else '') for x in sorted(left, key=lambda x: (-len(x.get('hits') or ()), -(len(x['up']) + x['un'])))[:6]) + (' …' if len(left) > 6 else '') + " (grep Type.name or axiomcode path to narrow)") + if left: lines.append(" " + ('+%d more: ' % len(left)) + ', '.join(f"{short(x['r']['display'])} ←{ups(x)}" + (f" →{len(x['dn'])}" if x['dn'] and not x['up'] else '') + (f" ?{x['un']}" if x['un'] else '') for x in sorted(left, key=lambda x: (-len(x.get('hits') or ()), -(len(x['up']) + x['un'])))[:6]) + (' …' if len(left) > 6 else '') + " (grep Type.name or axiomcode path to narrow)") elif tool == 'Grep' and not (inp.get('path') and os.path.relpath(os.path.realpath(os.path.join(cwd, os.path.expanduser(str(inp['path'])))), os.path.realpath(cwd)).split(os.sep)[0] == '..'): # (a Grep of a path outside this tree is about another codebase — nothing here to add) # a real search is rarely one identifier: `hasNext\(\)|\.next\(\)|close\(\)`, `getScanner|RTBoundValidator|withSSTablesIterated`. @@ -318,8 +342,27 @@ def lookup(n): # prefix it matches whatever starts with it (`once` -> onceAWeekTrigger). It is looked up exactly, never by prefix, # and kept below only when it connects to what the agent read this session — the `close` it was just reading. plain = n.islower() and '_' not in n - rows = c.execute("SELECT id, method_id, display, file, line FROM symbols WHERE name = ? AND method_id IS NOT NULL AND kind <> 'module' ORDER BY file LIMIT 12", (n,)).fetchall() \ - or ([] if plain else c.execute("SELECT id, method_id, display, file, line FROM symbols WHERE name LIKE ? AND method_id IS NOT NULL AND kind <> 'module' ORDER BY length(name), file LIMIT 12", (n + '%',)).fetchall()) + # A CONSTANT-SHAPED WORD IS NOT A CALLABLE'S NAME (`FAIL`, `DONE`, `PARSER`, `CACHE`), and the prefix match that + # used to follow was LIKE, which is case-blind: `FAIL` found `fail`, `PARSER` found `parserPresent`. The prefix is + # now case-sensitive (GLOB, which the name index also serves), and a grep run through the shell is matched as + # whole names only: its pattern is as often a word in a log as a name in code (#1604). + if re.fullmatch(r'[A-Z0-9_]+', n): return n, [], [] + COLS = "id, method_id, display, file, line, is_test" + rows = c.execute(f"SELECT {COLS} FROM symbols WHERE name = ? AND method_id IS NOT NULL AND kind <> 'module' ORDER BY file LIMIT 40", (n,)).fetchall() \ + or ([] if plain or _from_shell else c.execute(f"SELECT {COLS} FROM symbols WHERE name GLOB ? AND method_id IS NOT NULL AND kind <> 'module' ORDER BY length(name), file LIMIT 12", (n + '*',)).fetchall()) + # A NAME DECLARED SEVERAL TIMES IS A BASE AND ITS OVERRIDES (#1546): the base first, with how many override it, then + # production before tests, then by path. Ordered by path alone, two test stubs under `adapter-*` took both slots + # and the base, the main-source override and the count were all left out. + nover = {} + if len(rows) > 1: + has_ovr = c.execute("SELECT 1 FROM overrides LIMIT 1").fetchone() is not None if c.execute("SELECT 1 FROM sqlite_master WHERE name='overrides'").fetchone() else False + has_dc = c.execute("SELECT 1 FROM sqlite_master WHERE name='dispatch_candidates'").fetchone() is not None + for r in rows: + k = c.execute("SELECT count(DISTINCT overriding_method_id) FROM overrides WHERE method_id = ?", (r['method_id'],)).fetchone()[0] if has_ovr else 0 + if not k and not has_ovr and has_dc: # Python and TypeScript keep the envelope in dispatch_candidates + k = c.execute("SELECT count(DISTINCT candidate_method_id) FROM dispatch_candidates WHERE base_method_id = ? AND candidate_method_id <> base_method_id", (r['method_id'],)).fetchone()[0] + nover[r['id']] = k + rows = sorted(rows, key=lambda r: (-nover[r['id']], r['is_test'] or 0, r['file'])) # the declarations connected to what the agent just read: called BY a read callable, or CALLING one — first, and marked rel_ = {} unres = [] @@ -332,17 +375,27 @@ def lookup(n): e = c.execute(f"SELECT cr.id AS who, 'called from' AS how FROM call_edges e JOIN symbols cr ON cr.id = e.caller_id WHERE e.callee_method_id = ? AND e.caller_id IN ({ph}) LIMIT 1", (r['method_id'], *ctx)).fetchone() \ or c.execute(f"SELECT ce.id AS who, 'calls' AS how FROM call_edges e JOIN symbols ce ON ce.method_id = e.callee_method_id WHERE e.caller_id = ? AND ce.id IN ({ph}) LIMIT 1", (r['id'], *ctx)).fetchone() if e: rel_[r['id']] = (e['how'], ctx_names.get(e['who'], '?')) - rows = sorted(rows, key=lambda r: (r['id'] not in rel_, r['file'])) + rows = sorted(rows, key=lambda r: r['id'] not in rel_) # stable: the order above within each group if plain and not rel_ and not unres: return n, [], [] - rows = rows[:2] + total = len(rows); rows = rows[:2] out = [] - for r in rows: - up = c.execute("SELECT DISTINCT cr.display d FROM call_edges e JOIN symbols cr ON cr.id = e.caller_id WHERE e.callee_method_id = ? LIMIT 40", (r['method_id'],)).fetchall() + paths = _graphline.distinct_paths([r['file'] or '' for r in rows]) + for r, path in zip(rows, paths): + # PRODUCTION CALLERS ARE NAMED BEFORE TESTS (#1507): unordered, SQLite returned them by display, so two test + # methods took both name slots and the three production callers hid behind the count + up = c.execute("SELECT DISTINCT cr.display d, cr.is_test t FROM call_edges e JOIN symbols cr ON cr.id = e.caller_id WHERE e.callee_method_id = ? ORDER BY cr.is_test, cr.display LIMIT 40", (r['method_id'],)).fetchall() + nt = sum(1 for x in up if x['t']) # by DISPLAY, like the names printed beside it: two overloads of one callee are one name to the reader dn = c.execute("SELECT count(*) n FROM (SELECT DISTINCT ce.display FROM call_edges e JOIN symbols ce ON ce.method_id = e.callee_method_id WHERE e.caller_id = ? AND e.callee_provenance = 'client')", (r['id'],)).fetchone()['n'] un = c.execute("SELECT count(*) n FROM unresolved_sites WHERE caller_id = ?", (r['id'],)).fetchone()['n'] tag = f" ★ {rel_[r['id']][0]} {rel_[r['id']][1]} (which you just read)" if r['id'] in rel_ else '' - out.append(f" {r['display']} {os.path.basename(r['file'])}:{r['line']} ← {len(up)}" + (" (" + ', '.join(x['d'].split('.')[-1] for x in up[:2]) + (', …' if len(up) > 2 else '') + ")" if up else '') + f" → {dn}" + (f" ? {un}" if un else '') + tag) + # the tests are counted apart only below the 40-row cap: at the cap the split is not known + callers = (f"{len(up)} (" + ', '.join(x['d'].split('.')[-1] for x in up[:2]) + (', …' if len(up) > 2 else '') + + (f"; {nt} in tests" if nt and 2 < len(up) < 40 and nt < len(up) else '') + ")") if up \ + else _graphline.zero_label(c, r['method_id'], r['display'].rsplit('.', 1)[-1], r['is_test']) + out.append(f" {r['display']} {path}:{r['line']} ← {callers} → {dn}" + (f" ? {un}" if un else '') + + (f" ⇣ {nover[r['id']]} override(s)" if nover.get(r['id']) else '') + tag) + if total > len(rows) and out: out[-1] += f" (+{total - len(rows)} more declaration(s){'' if total < 40 else ' or more'})" if unres: out.append(f" ({ctx_names.get(unres[0]['caller_id'], '?')}, which you just read, calls a `{n}` at L{unres[0]['start_line']} whose receiver is not typed — it may be any of the above)") return n, rows, out if idents: diff --git a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py index 4663bae28..89ead0e11 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py @@ -247,7 +247,7 @@ def _at(f_, l_): 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, - reached=max(0, n - len(ids)), tests=t, test_names=test_names, depth=depth) + reached=max(0, n - len(ids)), tests=t, test_names=test_names, test_ids=sorted(tset), depth=depth) finally: con.close() @@ -290,7 +290,8 @@ def mk(d, role, cert): for d in r.get('test_names', [])[:tests_shown]] tests += [dict(display='', owner='', name='')] * max(0, r['tests'] - len(tests)) return dict(contract=[dict(display=d, why='overrides it', at=at.get(d, '')) for d in r['contract']], - direct=direct, reached=[None] * r['reached'], tests=tests, unresolved_inside=0, _sql=True) + direct=direct, reached=[None] * r['reached'], tests=tests, unresolved_inside=0, _sql=True, + test_ids=r.get('test_ids', [])) # the hooks build a runnable test command from these finally: con.close() diff --git a/tests/README.md b/tests/README.md index 1f2d8476b..5c404442c 100644 --- a/tests/README.md +++ b/tests/README.md @@ -35,6 +35,11 @@ One check needs no graph and is its own script: python3 tests/enrich_budget.py what a Read or a Grep adds to a session is capped: a declaration annotated once, the budget said spent once, its second half kept for edges into unopened files (#1199; indexes a small project, so it needs the engine) + python3 tests/enrich_lines.py what one enrichment line says: a caller count of 0 says why (entry point, by-name + sites, a framework annotation), production callers before tests, a base before its + overrides, no annotation for a shell grep over output or logs, nothing for an edit + that changes no declaration, one line of tests for a body edit (#1507, #1546, #1604; + indexes a small project, so it needs the engine) python3 tests/engine_choice.py axiomcode-build picks a built engine over an unbuilt clone it sits in, finds the engine the last build used before PATH (the refresh runs from a hook, with the hook's PATH), follows a Windows npm shim, and names every place it looked when diff --git a/tests/enrich_lines.py b/tests/enrich_lines.py new file mode 100644 index 000000000..29e3dad7e --- /dev/null +++ b/tests/enrich_lines.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +"""tests/enrich_lines.py: what one line of the Read / Grep / Edit enrichment says, where a bare number misled. + + · a caller count of 0 says why where the graph knows: `entry (http)`, `0 resolved, N by name`, `? framework (@X)`; + a method with no signal still reads `0` (the control); + · the Grep preview names production callers before tests (#1507), and a name declared several times lists the base + first with its override count, two different files never print as the same line, and the rest are counted (#1546); + · a grep run through the shell over another command's output or over files no graph indexes adds nothing, and a + constant-shaped word is not looked up as a callable (#1604); + · an edit that changes no declaration adds nothing, a body-only edit adds one line of reaching tests and the command + that runs them, and a declaration already reported this session is not reported again. + +Every silence has its control: the same hook on a near-miss input DOES speak, so a check cannot pass because the hook +had nothing to say. It indexes a small project, so it needs the engine, as run.py does. + + python3 tests/enrich_lines.py +""" +import json, os, shutil, subprocess, sys, tempfile + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +HOOKS = os.path.join(ROOT, 'plugins', 'axiomcode', 'hooks') +AX = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts', 'axiomcode') +P = 'core/src/main/java/app/orders/' + +SRC = { + 'pom.xml': '4.0.0appapp1\n', + P + 'OrderStore.java': 'package app.orders;\n\npublic class OrderStore {\n public String findById(int id) {\n return "order-" + id;\n }\n}\n', + P + 'OrderService.java': ('package app.orders;\n\npublic class OrderService {\n private final OrderStore store = new OrderStore();\n\n' + ' public String show(int id) { return store.findById(id); }\n\n' + ' public String cancel(int id) { return store.findById(id); }\n\n' + ' public String ship(int id) { return store.findById(id); }\n}\n'), + # sorts BEFORE OrderService: an unordered preview named these two and hid the production callers + 'core/src/test/java/app/orders/CheckoutTest.java': ('package app.orders;\n\nimport org.junit.jupiter.api.Test;\n\nclass CheckoutTest {\n' + ' @Test\n void findsAnOrder() { new OrderStore().findById(1); }\n\n' + ' @Test\n void findsAnotherOrder() { new OrderStore().findById(2); }\n}\n'), + P + 'Converter.java': ('package app.orders;\n\npublic interface Converter {\n String convert(Object value);\n\n' + ' abstract class Factory {\n public abstract Converter widgetConverter(String type);\n }\n}\n'), + P + 'JsonConverterFactory.java': ('package app.orders;\n\npublic class JsonConverterFactory extends Converter.Factory {\n' + ' public Converter widgetConverter(String type) { return v -> "json:" + v; }\n}\n'), + P + 'WidgetClient.java': ('package app.orders;\n\npublic class WidgetClient {\n private final java.util.Map registry = new java.util.HashMap();\n' + ' public String sendWidget(Converter.Factory f, Object w) {\n return f.widgetConverter("widget").convert(w);\n }\n' + ' public void prime(Object anything) {\n registry.lookup("jobs").nudgeAll();\n }\n}\n'), + P + 'Jobs.java': ('package app.orders;\n\nimport org.springframework.scheduling.annotation.Scheduled;\n' + 'import org.springframework.web.bind.annotation.GetMapping;\nimport org.springframework.web.bind.annotation.RestController;\n\n' + '@RestController\npublic class Jobs implements Runnable {\n' + ' @GetMapping("/orders")\n public String listOrders() { return new Plain().countAll(); }\n\n' + ' @Scheduled(fixedRate = 1000)\n public void sweepStale() { new Plain().countAll(); }\n\n' + ' @Override\n public void run() { new Plain().countAll(); }\n\n' + ' public void nudgeAll() { new Plain().countAll(); }\n\n' + ' public void pokeAll() { new Plain().countAll(); }\n}\n'), + P + 'Plain.java': ('package app.orders;\n\npublic class Plain {\n public String countAll() { return "1"; }\n' + ' public int unusedCount() { return Integer.parseInt(countAll()); }\n}\n'), +} +# two test stubs in modules whose paths sort before core/, with the same file name and line +for m in ('json', 'xml'): + SRC[f'adapter-{m}/src/test/java/app/{m}/StubConverterFactory.java'] = ( + f'package app.{m};\n\nimport app.orders.Converter;\n\npublic class StubConverterFactory extends Converter.Factory {{\n' + ' public Converter widgetConverter(String type) { return v -> "stub"; }\n}\n') + +fails, checked = [], [] +def check(why, cond, detail=''): + checked.append(why) + print(('ok ' if cond else 'FAIL ') + why + (f'\n {detail}' if not cond and detail else '')) + if not cond: + fails.append(why) + + +def fire(repo, session, tool, inp, hook='enrich.py', event='PostToolUse'): + ev = {'hook_event_name': event, 'tool_name': tool, 'tool_input': inp, 'cwd': repo, 'session_id': session, 'prompt': 'go on'} + r = subprocess.run([sys.executable, os.path.join(HOOKS, hook)], input=json.dumps(ev), capture_output=True, text=True, timeout=120) + out = r.stdout.strip() + if not out: + return '' + try: + return json.loads(out)['hookSpecificOutput']['additionalContext'] + except (ValueError, KeyError, TypeError): + return out + + +def grep(repo, pattern, session=None): + return fire(repo, session or f'g-{pattern}', 'Grep', {'pattern': pattern}) + + +def bash(repo, command, session=None): + return fire(repo, session or f'b-{command}', 'Bash', {'command': command}) + + +def line_of(block, name): + return next((l for l in block.splitlines() if name in l), '') + + +def git(repo, *a): + subprocess.run(['git', '-c', 'user.email=t@t', '-c', 'user.name=t', *a], cwd=repo, capture_output=True, check=True) + + +with tempfile.TemporaryDirectory() as repo: + for n, t in SRC.items(): + os.makedirs(os.path.dirname(os.path.join(repo, n)), exist_ok=True) + open(os.path.join(repo, n), 'w').write(t) + git(repo, 'init', '-q'); git(repo, 'add', '-A'); git(repo, 'commit', '-qm', 'init') + built = subprocess.run(['bash', AX, 'index', repo, '--lang', 'java'], capture_output=True, text=True, timeout=1800) + if not os.path.exists(os.path.join(repo, '.axiomcode', 'out', 'graph.sqlite')): + print('FAIL could not index the project; the engine is needed\n ' + built.stderr.strip()[-300:]) + sys.exit(1) + + # ── ← 0 says why ──────────────────────────────────────────────────────────────────────────────────── + g = grep(repo, 'listOrders|sweepStale|nudgeAll|pokeAll|unusedCount') + check('a route handler with no caller reads as an entry point, not as 0', '← entry (http)' in line_of(g, 'Jobs.listOrders'), g) + check('a scheduled method reads as an entry point', '← entry (scheduled)' in line_of(g, 'Jobs.sweepStale'), g) + check('a method whose name is written at an untyped call site counts that site', '← 0 resolved, 1 by name' in line_of(g, 'Jobs.nudgeAll'), g) + check('a method of a framework-annotated class says which annotation', '← ? framework (@RestController on Jobs)' in line_of(g, 'Jobs.pokeAll'), g) + check('control: an undecorated method nothing calls still reads 0', '← 0 →' in line_of(g, 'Plain.unusedCount'), g) + r = fire(repo, 'r1', 'Read', {'file_path': os.path.join(repo, P + 'Jobs.java')}) + check('a Read labels a library override the same way', 'run ←? framework (overrides a library method)' in r, r) + check('control: a Read of a method with callers still counts them', + 'countAll ←5' in fire(repo, 'r2', 'Read', {'file_path': os.path.join(repo, P + 'Plain.java')})) + + # ── the preview order (#1507) and several declarations (#1546) ─────────────────────────────────────── + g = grep(repo, 'findById') + check('production callers are named before test callers', '← 5 (cancel, ship, …; 2 in tests)' in g, g) + check('and the test callers are not the ones named', 'findsAnOrder' not in g, g) + g = grep(repo, 'widgetConverter') + rows = [l for l in g.splitlines() if l.startswith(' ')] + check('the base declaration comes first, with its override count', + rows and rows[0].lstrip().startswith('Converter.Factory.widgetConverter') and '⇣ 3 override(s)' in rows[0], g) + check('the production override comes before test stubs', len(rows) > 1 and 'JsonConverterFactory.widgetConverter' in rows[1], g) + check('the declarations not shown are counted', '(+2 more declaration(s))' in g, g) + check('two printed lines are never the same', len(rows) == len(set(rows)), g) + sys.path.insert(0, HOOKS); import _graphline + two = _graphline.distinct_paths(['adapter-json/src/test/java/app/json/Stub.java', 'adapter-xml/src/test/java/app/xml/Stub.java']) + check('two same-named files in different modules print with the path that tells them apart', two == ['json/Stub.java', 'xml/Stub.java'], two) + check('control: files with different names print as their names', _graphline.distinct_paths(['a/b/C.java', 'a/b/D.java']) == ['C.java', 'D.java']) + + # ── what the shell greps (#1604) ────────────────────────────────────────────────────────────────────── + check('a grep over another command\'s output adds nothing', bash(repo, "mvn test 2>&1 | grep -E 'findById|FAIL'") == '') + check('a grep over a log file adds nothing', bash(repo, 'grep -n findById build.log') == '') + check('a constant-shaped word is not looked up as a callable', bash(repo, 'grep -rn FIND core/src') == '') + check('a shell grep matches whole names, not prefixes', bash(repo, 'grep -rn findBy core/src') == '') + c = bash(repo, 'grep -rn findById core/src') + check('control: the same name grepped over source is annotated', 'OrderStore.findById' in c, c) + c = grep(repo, 'findBy', session='g-prefix') + check('control: the Grep tool still matches a name by (case-sensitive) prefix', 'OrderStore.findById' in c, c) + + # ── the edit hook ───────────────────────────────────────────────────────────────────────────────────── + f = os.path.join(repo, P + 'OrderStore.java') + orig = open(f).read() + def edit(session, text, hook='enrich.py', event='PostToolUse'): + open(f, 'w').write(text) + return fire(repo, session, 'Edit', {'file_path': f}, hook, event) + out = edit('e1', orig + '\n') + check('an edit that changes no declaration adds nothing', out == '', out) + body = orig.replace('"order-"', '"order:"') + out = edit('e2', body) + check('control: a body-only edit gets one line: the tests that reach it and the command that runs them', + out.count('\n') == 0 and 'body edit of OrderStore.findById: 2 test(s) reach it' in out and 'mvn test -Dtest=CheckoutTest' in out, out) + check('and not the readers, which a body edit cannot break', 'reads / uses it' not in out, out) + out = edit('e2', orig.replace('"order-"', '"order;"')) + check('the same declaration edited again in the session is not reported again', out == '', out) + check('control: in a fresh session it is', 'body edit of' in edit('e3', body)) + out = edit('e4', orig.replace('findById(int id)', 'findById(long id)')) + check('a signature edit keeps its blast radius, with impact answered', 'signature OrderStore.findById' in out + and 'reads / uses it' in out and 'impact unavailable' not in out, out) + open(f, 'w').write(body) + 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) + +print() +print(f"{len(checked) - len(fails)} of {len(checked)} check(s) held" if not fails else f"{len(fails)} FAILED: " + '; '.join(fails)) +sys.exit(1 if fails else 0)