From 7ce9d88014244db9ed9a9ff30f727fad1ccf392b Mon Sep 17 00:00:00 2001 From: swapnil Date: Wed, 30 Sep 2026 23:43:34 -0700 Subject: [PATCH 1/2] Aid grep instead of replacing it: a grep note says only what grep cannot know; impact shows code only where no text search reaches; find leaves MCP The hook after a grep now names, for each identifier, the declarations its matched lines reach when they differ, and the callers whose call never spells the name; it is silent when grep was complete. impact lists the places a word grep already finds by location only, names places further than one hop and the tests without code, and keeps the code for the direct places grep cannot see. MCP offers impact, path and tests; find stays a shell verb. AXIOMCODE_GREP_AID=0 restores the previous answers. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- plugins/axiomcode/AGENTS.md | 8 +- plugins/axiomcode/hooks/direct.py | 3 +- plugins/axiomcode/hooks/enrich.py | 72 +++++++++++++++- plugins/axiomcode/hooks/orient.py | 11 ++- plugins/axiomcode/mcp/server.py | 9 +- plugins/axiomcode/rules/axiomcode.mdc | 8 +- plugins/axiomcode/skills/axiomcode/SKILL.md | 10 +-- .../skills/axiomcode/scripts/ax_blocks.py | 83 +++++++++++++++++++ .../axiomcode/scripts/axiomcode-install | 5 +- skills/axiomcode/SKILL.md | 8 +- tests/front_door.py | 14 ++-- tests/mcp.py | 10 +-- tests/mcp_docs.py | 4 +- tests/mcp_first.py | 12 +-- tests/repo_arg.py | 2 +- tests/surfaces.py | 3 +- 16 files changed, 202 insertions(+), 60 deletions(-) diff --git a/plugins/axiomcode/AGENTS.md b/plugins/axiomcode/AGENTS.md index ac48bfde1..1f6b1fcf1 100644 --- a/plugins/axiomcode/AGENTS.md +++ b/plugins/axiomcode/AGENTS.md @@ -1,15 +1,15 @@ # axiomcode -For any why, what or where question about code — where something lives, who calls it, what a change -breaks, which tests an edit reaches — ask the repository's call graph FIRST, through the axiomcode MCP tools: +Search with grep and Read as usual: after a grep, the call graph adds only what grep cannot know (which +declaration each match reaches, the callers that never spell the name). Ask it directly, through the axiomcode +MCP tools, for what no text search answers: - find(question) where the code for a task lives, when you have a task in words and no name yet impact(name) who calls it, what a change to it reaches, and its tests; impact() with no name: the same for your uncommitted edits path(start, end) how A reaches B, every hop of the call chain tests() the tests your uncommitted edits reach, and the command that runs them -Without the tools, the same from the shell: `axiomcode find ""`, `axiomcode impact `, +Without the tools, the same from the shell: `axiomcode impact `, `axiomcode path `, `axiomcode tests`. Every answer is a numbered list of places, each with the code of the function it sits in and the line that diff --git a/plugins/axiomcode/hooks/direct.py b/plugins/axiomcode/hooks/direct.py index 7a4285b85..58ca74d4b 100755 --- a/plugins/axiomcode/hooks/direct.py +++ b/plugins/axiomcode/hooks/direct.py @@ -124,8 +124,7 @@ def directive(hits): f"graph: this search is for {named}. Who calls it and what a change breaks, each with its code,\n" f" including the callers that never spell the name (an interface, an override, a callback, DI):\n" f" impact(name=\"{at}\") (mcp__plugin_axiomcode_axiomcode__impact; shell `axiomcode impact {at}`). Also\n" - f" path(start, end) for how A reaches B (`axiomcode path A B`), find(question) for a task in words\n" - f" (`axiomcode find \"\"`). Said once this session." + f" path(start, end) for how A reaches B (`axiomcode path A B`). Said once this session." ) diff --git a/plugins/axiomcode/hooks/enrich.py b/plugins/axiomcode/hooks/enrich.py index 036e7f4ac..64b3f04c0 100755 --- a/plugins/axiomcode/hooks/enrich.py +++ b/plugins/axiomcode/hooks/enrich.py @@ -132,6 +132,74 @@ def outside(p): # Seeded from one method the same closure is small, and the cap keeps a hub method flat. Each edge table joins in # its OWN recursive branch so SQLite drives them by index; one combined edge CTE rescans every edge per call. REACH_DEPTH = 6 +GREP_HIT = re.compile(r'(?:^|[\s"\'])((?:[\w.@-]+/)*[\w.@-]+\.\w+)(?::(\d+))?') +WORD = lambda n: re.compile(r'(? 'module'", n) + if not decls: continue + mids = {d['method_id']: d for d in decls} + ph = ','.join('?' * len(mids)) + edges = q(f"SELECT cs.file_path f, cs.start_line ln, coalesce(cs.end_line, cs.start_line) eln, cs.callee_name cn, e.callee_method_id m, cr.display who FROM call_edges e " + f"JOIN call_sites cs ON cs.id = e.call_site_id JOIN symbols cr ON cr.id = e.caller_id " + f"WHERE e.callee_method_id IN ({ph})", *mids) + # 1. the calls grep matched, split by the declaration they reach — only when the name is declared more than once + # A call through an interface reaches the base and every implementation: that is one target set, not an ambiguity. + # Only lines that reach DIFFERENT sets are worth telling apart, because that is what grep's lines cannot show. + split = '' + if len(decls) > 1: + mine = [x for x in edges if (x['f'], x['ln']) in hit_lines] if hit_lines else [x for x in edges if x['f'] in hit_files and x['cn'] == n] + per_line = collections.defaultdict(set) + for x in mine: per_line[(x['f'], x['ln'])].add(mids[x['m']]['display']) + groups = collections.defaultdict(list) + for k, ds in per_line.items(): groups[frozenset(ds)].append(k) + if len(groups) > 1: + def name(ds): return min(ds, key=len) + (f" (+{len(ds) - 1} implementation(s))" if len(ds) > 1 else '') + def at(ks): + ks = sorted(ks); fs = collections.OrderedDict() + for f, ln in ks: fs.setdefault(f.split('/')[-1], []).append(str(ln)) + return ' '.join(f"{f}:{','.join(v[:3])}" for f, v in list(fs.items())[:2]) + (' …' if len(fs) > 2 else '') + split = '; '.join(f"{len(ks)} → {name(ds)} [{at(ks)}]" for ds, ks in sorted(groups.items(), key=lambda kv: -len(kv[1]))[:3]) + # 2. the callers no text search finds: the call's line does not spell the name + hidden = [] + for x in edges: + # a call spans lines (`rows\n .sort(byPath)` starts a line above its name): the whole span is checked + src = [linecache_line(x['f'], i) for i in range(x['ln'], max(x['ln'], x['eln']) + 1)] + if None not in src and not any(WORD(n).search(l) for l in src) and (x['f'], x['ln']) not in hit_lines: + hidden.append(f"{x['f']}:{x['ln']} ({x['who'].split('.')[-1]})") + hidden = list(dict.fromkeys(hidden)) + if not split and not hidden: continue + parts = [] + if split: parts.append(f"your matches reach different declarations: {split}") + if hidden: parts.append(f"{len(hidden)} caller(s) grep cannot see (the line never names it): " + ', '.join(hidden[:4]) + (f" +{len(hidden) - 4}" if len(hidden) > 4 else '')) + out.append(f"graph on `{n}`: " + ' | '.join(parts)) + return out + + +_LINES = {} +def linecache_line(f, ln): + if f not in _LINES: + try: + with open(os.path.join(cwd, f), encoding='utf-8', errors='replace') as h: _LINES[f] = h.read().split('\n') + except OSError: _LINES[f] = None + L = _LINES[f] + return L[ln - 1] if L and 0 < ln <= len(L) else None + + def reach_counts(mid): try: tot = q("SELECT count(*) n FROM symbols WHERE is_test = 1 AND method_id IS NOT NULL")[0]['n'] @@ -477,7 +545,9 @@ def lookup(n): 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: + if idents and os.environ.get('AXIOMCODE_GREP_AID', '1') != '0': + lines += grep_aid(idents, ev.get('tool_response')) + elif idents: with concurrent.futures.ThreadPoolExecutor(max_workers=min(6, len(idents))) as ex: found = list(ex.map(lookup, idents)) found = [(n, rows, out) for n, rows, out in found if rows] opened = set(load_state().get('opened', [])) diff --git a/plugins/axiomcode/hooks/orient.py b/plugins/axiomcode/hooks/orient.py index 316cfc7b5..c355b8505 100755 --- a/plugins/axiomcode/hooks/orient.py +++ b/plugins/axiomcode/hooks/orient.py @@ -196,9 +196,9 @@ def names_code(prompt, db): # rather than restating that there was a match, which told the reader nothing about WHICH match _, _, hits = rest.partition('<- ') print(f" {path}" + (f" <- {hits.strip()}" if hits.strip() else '')) - print(' find(question="") (mcp__plugin_axiomcode_axiomcode__find) ranks the functions the task lands in, ' - 'each with its code; call it directly, no skill needs loading first (without that tool: ' - '`axiomcode find ""`).') + print(' search these with grep as usual: after each grep the graph says only what grep cannot see (which declaration ' + 'a match reaches, callers that never spell the name). For a name: impact(name="") ' + '(mcp__plugin_axiomcode_axiomcode__impact; shell `axiomcode impact `).') else: print("graph: where this task's own words land in the index —") for l in lines[:MAX_LINES]: @@ -210,9 +210,8 @@ def names_code(prompt, db): if 'how it runs —' in out: # a how-question: the flow is the answer's spine, and the call that returns it with each step's code is the # one to make — named here so no turn goes to loading the skill or the tool schemas first - print(' next: find(question="") (mcp__plugin_axiomcode_axiomcode__find) returns the functions ' - 'the flow runs through, each with its code; call it directly, no skill needs loading first (without that ' - 'tool: `axiomcode find ""`).') + print(' next: grep the names above as usual; for how one reaches another, path(start="", end="") ' + '(mcp__plugin_axiomcode_axiomcode__path; shell `axiomcode path `) returns every hop with its code.') else: print(' a starting point, not a conclusion: next, impact(name="") (mcp__plugin_axiomcode_axiomcode__impact) ' 'for who calls it, what a change reaches and its tests, each with its code; call it directly, no skill ' diff --git a/plugins/axiomcode/mcp/server.py b/plugins/axiomcode/mcp/server.py index 2f30e1423..4a9821ffb 100755 --- a/plugins/axiomcode/mcp/server.py +++ b/plugins/axiomcode/mcp/server.py @@ -313,15 +313,8 @@ def plain(text): return '\n'.join(out) -# THE SMALL SURFACE. Three questions, each answered as numbered places with the code of the function each sits in, so +# THE SMALL SURFACE. Search is grep's job; the graph answers what grep cannot. Three questions, each answered as numbered places with the code of the function each sits in, so # a place is understood without opening its file. No options: the repository is the one the session works in. -@srv.tool() -def find(question: str) -> str: - """Where the code for a task lives. Describe what you need in words (the feature, the behaviour, a name you saw); - get the functions involved, each with its code, most relevant first. A name the code calls but nothing declares - is listed with its call sites: that is code you have to write.""" - return plain(run(['find', question, os.getcwd()])) - @srv.tool() def impact(name: str = '') -> str: """What a change reaches. With a name (as written in the code: Owner.method, function, Type, or file.py:123): who diff --git a/plugins/axiomcode/rules/axiomcode.mdc b/plugins/axiomcode/rules/axiomcode.mdc index d77c4ea52..05b30d8a9 100644 --- a/plugins/axiomcode/rules/axiomcode.mdc +++ b/plugins/axiomcode/rules/axiomcode.mdc @@ -5,16 +5,16 @@ alwaysApply: true # axiomcode -For any why, what or where question about code — where something lives, who calls it, what a change -breaks, which tests an edit reaches — ask the repository's call graph FIRST, through the axiomcode MCP tools: +Search with grep and Read as usual: after a grep, the call graph adds only what grep cannot know (which +declaration each match reaches, the callers that never spell the name). Ask it directly, through the axiomcode +MCP tools, for what no text search answers: - find(question) where the code for a task lives, when you have a task in words and no name yet impact(name) who calls it, what a change to it reaches, and its tests; impact() with no name: the same for your uncommitted edits path(start, end) how A reaches B, every hop of the call chain tests() the tests your uncommitted edits reach, and the command that runs them -Without the tools, the same from the shell: `axiomcode find ""`, `axiomcode impact `, +Without the tools, the same from the shell: `axiomcode impact `, `axiomcode path `, `axiomcode tests`. Every answer is a numbered list of places, each with the code of the function it sits in and the line that diff --git a/plugins/axiomcode/skills/axiomcode/SKILL.md b/plugins/axiomcode/skills/axiomcode/SKILL.md index 3628a493d..9deac5747 100644 --- a/plugins/axiomcode/skills/axiomcode/SKILL.md +++ b/plugins/axiomcode/skills/axiomcode/SKILL.md @@ -1,18 +1,18 @@ --- name: axiomcode description: >- - Use for any why, what or where question about code — how a codebase works, where something lives, who calls it, what a change to it breaks, which tests cover an edit. Also use when resolving an issue or bug report, which names a symptom rather than a file. Examples: "How does X work?", "Where do I change Y?", "What calls this?", "What breaks if I change Z?", "Which tests do I run?", "Fix this issue". No task is too small: if you are about to grep for a name, call this instead. Mandatory when .axiomcode/out/graph.sqlite exists — start here rather than grep, even when you already know the code. Answers come from a resolved call graph, so they include callers that never spell the name — through an interface, an override, a callback, dependency injection or a config key — and every place comes with the code of the function it sits in. Call the MCP tools directly, no need to load this skill first: find(question) for where the code for a task lives, impact(name) for who calls it and what a change reaches (with no name: your uncommitted edits), path(start, end) for how A reaches B, tests() for the tests your edits reach. Only when those tools are not in your list, the same from the shell: `axiomcode find ""`, `axiomcode impact `, `axiomcode path `, `axiomcode tests`. Java, TypeScript, Python, JavaScript, C#. + Use for any why, what or where question about code — how a codebase works, where something lives, who calls it, what a change to it breaks, which tests cover an edit. Also use when resolving an issue or bug report, which names a symptom rather than a file. Examples: "How does X work?", "Where do I change Y?", "What calls this?", "What breaks if I change Z?", "Which tests do I run?", "Fix this issue". Search with grep as usual: after a grep the graph adds only what grep cannot know — which declaration each match reaches and the callers that never spell the name. Answers come from a resolved call graph, so they include callers that never spell the name — through an interface, an override, a callback, dependency injection or a config key — and every place comes with the code of the function it sits in. Call the MCP tools directly, no need to load this skill first: impact(name) for who calls it and what a change reaches (with no name: your uncommitted edits), path(start, end) for how A reaches B, tests() for the tests your edits reach. Only when those tools are not in your list, the same from the shell: `axiomcode impact `, `axiomcode path `, `axiomcode tests`. Java, TypeScript, Python, JavaScript, C#. --- # axiomcode -Four questions, asked of the repository's call graph. Use the MCP tools when they are in your list (in Claude Code -`mcp__plugin_axiomcode_axiomcode__find`, `__impact`, `__path`, `__tests`); otherwise run +Search with grep as usual; the graph answers what grep cannot. Use the MCP tools when they are in your list (in Claude Code +`mcp__plugin_axiomcode_axiomcode__impact`, `__path`, `__tests`); otherwise run `/scripts/axiomcode ` from the repository root. Same answer either way. | the question | MCP tool | shell | |---|---|---| -| where is the code for this task? | `find(question)` | `axiomcode find ""` | +| where is the code for this task? | grep; or, shell only | `axiomcode find ""` | | who calls X, what does changing it reach, which tests? | `impact(name)` | `axiomcode impact ` | | what do my uncommitted edits reach? | `impact()` | `axiomcode impact` | | how does A reach B? | `path(start, end)` | `axiomcode path ` | @@ -44,7 +44,7 @@ from an empty answer. Where the code for a task lives, when you have a task in words and no name yet: the functions involved, most relevant first, each with its code. A name the code calls but nothing declares is listed with its call sites — that is -code you have to write. Example: `find(question="how is the invoice total computed")`. +code you have to write. Shell only: `axiomcode find "how is the invoice total computed"`. ## impact diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py index 731e1d73c..09574d9c9 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py @@ -19,6 +19,7 @@ import ax_grep CAP = 10 # places shown; the rest are counted +FAR = 8 # places more than one hop away, named without code WHOLE = 14 # a function this short is shown whole AROUND = 3 # else: its header, then this many lines either side of the line that matters FENCE = {'.py': 'python', '.java': 'java', '.ts': 'typescript', '.tsx': 'tsx', '.js': 'javascript', '.jsx': 'jsx', @@ -100,6 +101,25 @@ def render(verb, doc, repo): if n not in p['marks']: p['marks'].append(n) t = t.split(' — ')[0].strip() # the tag's short form: what it is, not the explanation after the dash if t and t not in p['tags']: p['tags'].append(t) + # GREP AIDS, IT IS NOT REPLACED. For impact, a place whose lines spell the target's name is one the agent's own + # `grep -nw NAME` finds: it is listed by location only, and the code goes to the places no text search reaches + # (a function passed as a value, a framework or DI registration, an alias). Off with AXIOMCODE_GREP_AID=0. + plain = [] + names = target_names(doc) if verb == 'impact' and os.environ.get('AXIOMCODE_GREP_AID', '1') != '0' else [] + if names: + spelled = lambda p: all(any(re.search(r'(? FAR: out.append(f" … +{len(far) - FAR} more") + if tests: + out.append(f"tests that reach it ({len(tests)}; no code shown):") + for p in tests[:FAR]: + out.append(f" {p['f']}:{','.join(map(str, sorted(p['marks'])))} {p['span'][0] if p['span'] else '?'}") + if len(tests) > FAR: out.append(f" … +{len(tests) - FAR} more") + if plain: + refs = [f"{p['f']}:{','.join(map(str, sorted(p['marks'])))}" for p in plain] + g = ' -e '.join(names) + out.append(f"{'' if out else 'every place spells the name, so grep finds them all — '}+{len(plain)} place(s) `grep -nw {g}` also finds " + f"(confirmed callers; no code shown): " + ', '.join(refs[:8]) + (f" +{len(refs) - 8}" if len(refs) > 8 else '')) + other = grep_others(repo, names, {(p['f'], n) for p in plain for n in p['marks']}) + if other: out.append(f" {other} other line(s) grep matches for that name are NOT this declaration (another symbol of the same name, or text)") if not out: return None if len(places) > CAP: out.append(f"… {len(places) - CAP} more place(s) not shown — ask a narrower question to see them") out += [x for x in foot if x.startswith(('run:', 'verified'))][:2] return out +def target_names(doc): + """the short names of the declarations impact was asked about: `isOrderable (4 declarations)`, `Shop.Cart#total` -> total""" + out = [] + for t in doc.get('targets') or []: + lab = str(t.get('label') or t.get('display') or '').split(' (')[0].strip() + nm = re.split(r'[.#:]', lab)[-1].split('(')[0].strip() + if re.fullmatch(r'[A-Za-z_$][\w$]*', nm or '') and nm not in out: out.append(nm) + return out + + +_SRC = {} +def source_line(repo, f, n): + if f not in _SRC: + try: + with open(os.path.join(repo, f), encoding='utf-8', errors='replace') as h: _SRC[f] = h.read().split('\n') + except OSError: _SRC[f] = [] + L = _SRC[f] + return L[n - 1] if 0 < n <= len(L) else '' + + +def statement(repo, f, n): + """the statement a call starts on: up to the line that ends it (`rows\\n .sort(byPath);`), at most 5 lines""" + out = [] + for i in range(n, n + 5): + l = source_line(repo, f, i); out.append(l) + if l.rstrip().endswith((';', '{', '}')) or not l.strip(): break + return '\n'.join(out) + + +def grep_others(repo, names, confirmed): + """how many lines a word grep for the name matches that are neither a confirmed place nor a declaration of it""" + try: + r = subprocess.run(['git', 'grep', '-nw'] + sum((['-e', nm] for nm in names), []), cwd=repo, capture_output=True, text=True, timeout=10) + except (OSError, subprocess.SubprocessError): + return 0 + n = 0 + for ln in r.stdout.splitlines(): + m = re.match(r'([^:]+):(\d+):(.*)', ln) + if not m or (m.group(1), int(m.group(2))) in confirmed: continue + if re.search(r'\b(def|function|class|interface|async|public|private|protected|static|const|let|var)\b[^=(]*\b(' + '|'.join(map(re.escape, names)) + r')\b', m.group(3)): continue + if re.match(r'\s*(' + '|'.join(map(re.escape, names)) + r')\s*\(.*\)\s*[:{]', m.group(3)): continue # a method declaration + n += 1 + return n + + def verb_json(cmd): import ax_exec r = subprocess.run(ax_exec.program(cmd + ['--json']), stdout=subprocess.PIPE, text=True, encoding='utf-8', errors='replace') diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-install b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-install index c562c060c..a3a9f80b5 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-install +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-install @@ -35,10 +35,9 @@ END = '' BLOCK = """ ## Finding code in this repository -This repository has a resolved call graph. Ask it FIRST, through the axiomcode MCP tools -(`mcp__plugin_axiomcode_axiomcode__*`); no skill needs loading: +This repository has a resolved call graph. Search with grep as usual; after a grep it adds what grep cannot know. +Ask it directly, through the axiomcode MCP tools (`mcp__plugin_axiomcode_axiomcode__*`; no skill needs loading): - find(question="") # where the code for a task lives impact(name="") # who calls it, what a change reaches, its tests impact() # the same for your uncommitted edits path(start="", end="") # how A reaches B diff --git a/skills/axiomcode/SKILL.md b/skills/axiomcode/SKILL.md index ff9eb64f5..bbc24624d 100644 --- a/skills/axiomcode/SKILL.md +++ b/skills/axiomcode/SKILL.md @@ -1,18 +1,18 @@ --- name: axiomcode description: >- - Use for any why, what or where question about code — how a codebase works, where something lives, who calls it, what a change to it breaks, which tests cover an edit. Also use when resolving an issue or bug report, which names a symptom rather than a file. Examples: "How does X work?", "Where do I change Y?", "What calls this?", "What breaks if I change Z?", "Which tests do I run?", "Fix this issue". No task is too small: if you are about to grep for a name, call this instead. Mandatory when .axiomcode/out/graph.sqlite exists — start here rather than grep, even when you already know the code. Answers come from a resolved call graph, so they include callers that never spell the name — through an interface, an override, a callback, dependency injection or a config key — and every place comes with the code of the function it sits in. Call the MCP tools directly, no need to load this skill first: find(question) for where the code for a task lives, impact(name) for who calls it and what a change reaches (with no name: your uncommitted edits), path(start, end) for how A reaches B, tests() for the tests your edits reach. Only when those tools are not in your list, the same from the shell: `axiomcode find ""`, `axiomcode impact `, `axiomcode path `, `axiomcode tests`. Java, TypeScript, Python, JavaScript, C#. + Use for any why, what or where question about code — how a codebase works, where something lives, who calls it, what a change to it breaks, which tests cover an edit. Also use when resolving an issue or bug report, which names a symptom rather than a file. Examples: "How does X work?", "Where do I change Y?", "What calls this?", "What breaks if I change Z?", "Which tests do I run?", "Fix this issue". Search with grep as usual: after a grep the graph adds only what grep cannot know — which declaration each match reaches and the callers that never spell the name. Answers come from a resolved call graph, so they include callers that never spell the name — through an interface, an override, a callback, dependency injection or a config key — and every place comes with the code of the function it sits in. Call the MCP tools directly, no need to load this skill first: impact(name) for who calls it and what a change reaches (with no name: your uncommitted edits), path(start, end) for how A reaches B, tests() for the tests your edits reach. Only when those tools are not in your list, the same from the shell: `axiomcode impact `, `axiomcode path `, `axiomcode tests`. Java, TypeScript, Python, JavaScript, C#. --- # axiomcode Four questions, asked of the repository's call graph. Use the MCP tools when they are in your list (in Claude Code -`mcp__plugin_axiomcode_axiomcode__find`, `__impact`, `__path`, `__tests`); otherwise run +`mcp__plugin_axiomcode_axiomcode__impact`, `__path`, `__tests`); otherwise run `/../../plugins/axiomcode/skills/axiomcode/scripts/axiomcode ` from the repository root. Same answer either way. | the question | MCP tool | shell | |---|---|---| -| where is the code for this task? | `find(question)` | `axiomcode find ""` | +| where is the code for this task? | grep; or, shell only | `axiomcode find ""` | | who calls X, what does changing it reach, which tests? | `impact(name)` | `axiomcode impact ` | | what do my uncommitted edits reach? | `impact()` | `axiomcode impact` | | how does A reach B? | `path(start, end)` | `axiomcode path ` | @@ -44,7 +44,7 @@ from an empty answer. Where the code for a task lives, when you have a task in words and no name yet: the functions involved, most relevant first, each with its code. A name the code calls but nothing declares is listed with its call sites — that is -code you have to write. Example: `find(question="how is the invoice total computed")`. +code you have to write. Shell only: `axiomcode find "how is the invoice total computed"`. ## impact diff --git a/tests/front_door.py b/tests/front_door.py index 73f2d0344..58ff997a6 100644 --- a/tests/front_door.py +++ b/tests/front_door.py @@ -99,8 +99,8 @@ def main(): rc, out, err = cli(repo, 'find', 'how is the invoice total computed') check('find: numbered places, each with its code in a fenced block', rc == 0 and places(out) and 'def invoice' in out, out[:600] + err[-300:]) rc, out, err = cli(repo, 'impact', 'vat_rate') - check('impact : its caller as a numbered place with its code', rc == 0 and places(out) and 'shop/pricing.py:6' in out - and 'return net * (1 + vat_rate())' in out, out[:600] + err[-300:]) + check('impact : its caller, which spells the name, by location on the grep line (no code)', rc == 0 and 'grep -nw vat_rate' in out and 'shop/pricing.py:6' in out + and 'return net * (1 + vat_rate())' not in out, out[:600] + err[-300:]) check('impact : the test that reaches it is one of the places', 'tests/test_pricing.py' in out, out[:800]) rc, out, err = cli(repo, 'path', 'invoice', 'vat_rate') check('path: every hop a numbered place with the code at the call', rc == 0 and places(out) @@ -111,20 +111,20 @@ def main(): first = out.lstrip().split('\n', 1)[0] check('impact with no name: the answer starts with "your edits:" and names the edited declaration', rc == 0 and first.startswith('your edits:') and 'vat_rate' in first, out[:600] + err[-300:]) - check('impact with no name: then what the edit reaches, as places with their code', places(out) and 'shop/pricing.py:6' in out, out[:600]) + check('impact with no name: then what the edit reaches, its grep-visible caller by location', 'shop/pricing.py:6' in out, out[:600]) rc, out, err = cli(repo, 'tests') last = [l for l in out.splitlines() if l.strip()][-1:] or [''] check('tests: the reached test as a numbered place with its code', rc == 0 and places(out) and 'tests/test_pricing.py' in out, out[:600] + err[-300:]) check('tests: the answer ends with the "run:" line', last[0].startswith('run:') and 'test_pricing' in last[0], last) # ── b. the MCP server ────────────────────────────────────────────────────────────────────────────────────── - got = mcp(repo, [('find', {'question': 'how is the invoice total computed'}), ('impact', {'name': 'vat_rate'})]) + got = mcp(repo, [('path', {'start': 'total', 'end': 'vat_rate'}), ('impact', {'name': 'vat_rate'})]) tools = {t['name']: list((t.get('inputSchema') or {}).get('properties', {})) for t in got.get(2, {}).get('tools', [])} - check('MCP tools/list is exactly find, impact, path and tests', set(tools) == {'find', 'impact', 'path', 'tests'}, tools) + check('MCP tools/list is exactly impact, path and tests (search is grep\'s)', set(tools) == {'impact', 'path', 'tests'}, tools) check('MCP: every tool takes at most two parameters', bool(tools) and all(len(p) <= 2 for p in tools.values()), tools) text = lambda i: ''.join(c.get('text', '') for c in got.get(i, {}).get('content', [])) - check('MCP find answers as numbered places with their code', places(text(3)), text(3)[:600]) - check('MCP impact answers as numbered places with their code', places(text(4)) and 'shop/pricing.py:6' in text(4), text(4)[:600]) + check('MCP path answers as numbered places with their code', places(text(3)), text(3)[:600]) + check('MCP impact names the caller grep also finds, by location', 'shop/pricing.py:6' in text(4) and 'grep -nw vat_rate' in text(4), text(4)[:600]) # ── c. controls: the same question anywhere else gets the verb's own answer ────────────────────────────────── r = subprocess.run(['bash', AX, 'impact', 'vat_rate', repo], cwd=repo, capture_output=True, text=True, timeout=600, env=ENV) diff --git a/tests/mcp.py b/tests/mcp.py index 91f08c171..7412ab723 100644 --- a/tests/mcp.py +++ b/tests/mcp.py @@ -31,7 +31,7 @@ SERVER = os.path.join(ROOT, 'plugins', 'axiomcode', 'mcp', 'server.py') # THE SMALL SURFACE: four questions, each with at most two parameters and no options. The front-door answer is capped # at ten places with the rest counted, so no tool is paged. -TOOLS = {'find': ['question'], 'impact': ['name'], 'path': ['start', 'end'], 'tests': []} +TOOLS = {'impact': ['name'], 'path': ['start', 'end'], 'tests': []} def exchange(cmd, cwd, env=None, workdir=None): @@ -112,10 +112,9 @@ def check_arguments(label, cmd, cwd, lax=False): wrong = [('impact', {'name': ['Excluder.excludeClass']}, 'name'), ('path', {'start': 'a'}, 'end'), ('path', {'start': 'a', 'end': 'b', 'nope': 1}, 'nope'), - ('find', {}, 'question'), # the options the old tools took are refused, not dropped so that an unnarrowed answer comes back as if # it had been narrowed (#1567): the repository is the session's, and there are no flags - ('find', {'question': 'x', 'in_path': 'src'}, 'in_path: unexpected argument'), + ('path', {'start': 'a', 'end': 'b', 'in_path': 'src'}, 'in_path: unexpected argument'), ('impact', {'name': 'A.f', 'repo': cwd}, 'repo: unexpected argument'), ('tests', {'why': True}, 'why: unexpected argument')] for name, args, field in wrong: @@ -125,7 +124,7 @@ def check_arguments(label, cmd, cwd, lax=False): if not res.get('isError') or field not in text or not ('invalid arguments' in text or 'validation error' in text): bad.append(f"{label}: {name}({json.dumps(args)}) was not refused naming {field!r}: {res}") # the control: every parameter a tool declares still passes, including the ones the CLI's hints name - right = [('find', {'question': 'x'}), ('impact', {'name': 'A.f'}), ('impact', {}), + right = [('impact', {'name': 'A.f'}), ('impact', {}), ('path', {'start': 'a', 'end': 'b'}), ('tests', {})] for name, args in right: res = call(cmd, cwd, name, args) @@ -181,8 +180,7 @@ def check_front_door(): cwd = os.getcwd() try: bad = [] - for call, want in ((lambda: server.find('how is a total computed'), ['find', 'how is a total computed', cwd]), - (lambda: server.impact('A.f'), ['impact', 'A.f', cwd]), + for call, want in ((lambda: server.impact('A.f'), ['impact', 'A.f', cwd]), (lambda: server.impact(''), ['impact', cwd]), (lambda: server.impact(), ['impact', cwd]), (lambda: server.path('a', 'b'), ['path', 'a', 'b', cwd]), diff --git a/tests/mcp_docs.py b/tests/mcp_docs.py index c6da2d9b7..e2925fb86 100644 --- a/tests/mcp_docs.py +++ b/tests/mcp_docs.py @@ -131,7 +131,7 @@ def controls(tools): got = len(mismatches(documented(text), fake, 'control')) if got != want: bad.append(f"control {text!r}: {got} mismatch(es), want {want}") - if set(tools) != {'find', 'impact', 'path', 'tests'}: + if set(tools) != {'impact', 'path', 'tests'}: bad.append(f"the server listed {sorted(tools)}; the MCP tools were not read") return bad @@ -147,7 +147,7 @@ def main(): seen |= {(t, a) for ts, a, _v, _u in rows if ts for t in ts} bad += mismatches(rows, tools, rel) # not a vacuous pass: the arguments the docs are known to teach were found and checked - for want in (('find', 'question'), ('impact', 'name'), ('path', 'start'), ('path', 'end')): + for want in (('impact', 'name'), ('path', 'start'), ('path', 'end')): if want not in seen: bad.append(f"the docs' {want[0]} {want[1]}= was not found, so the reader missed it") print(f"{len(DOCS)} doc(s), {len(seen)} documented (tool, argument) pair(s) checked against {len(tools)} tool schema(s)") diff --git a/tests/mcp_first.py b/tests/mcp_first.py index addad02c8..230abb0eb 100644 --- a/tests/mcp_first.py +++ b/tests/mcp_first.py @@ -60,13 +60,13 @@ def fire(hook, ev): m = re.search(r'^description: >-\n(.*?)\n---', skill, re.S | re.M) check('SKILL.md has a description block', bool(m)) if m: - tool_first('SKILL.md description', ' '.join(m.group(1).split()), ('find', 'impact', 'path', 'tests')) + tool_first('SKILL.md description', ' '.join(m.group(1).split()), ('impact', 'path', 'tests')) # 2. the block `axiomcode install` writes into CLAUDE.md, beside the permission it grants r = subprocess.run([sys.executable, os.path.join(SCRIPTS, 'axiomcode-install'), '--print'], capture_output=True, text=True, timeout=30) check('install --print prints the block', r.returncode == 0 and 'BEGIN axiomcode' in r.stdout, r.stderr[-200:]) -tool_first('install block', r.stdout, ('find', 'impact', 'path', 'tests')) +tool_first('install block', r.stdout, ('impact', 'path', 'tests')) # 3. the directive before the first search for a name the graph declares with tempfile.TemporaryDirectory() as repo: @@ -80,7 +80,7 @@ def fire(hook, ev): 'tool_input': {'pattern': 'findOrder'}, 'cwd': repo, 'session_id': 'm1'}) check('direct: the first search for a declared name hears the directive', rc == 0 and bool(said), f'rc={rc}') # only the verbs that answer a search: tests is about an edit, not about what a grep looks for - tool_first('direct', said, ('impact', 'path', 'find')) + tool_first('direct', said, ('impact', 'path')) # 4. the orientation on the first prompt, both branches it can reach: a change question and a how-question with tempfile.TemporaryDirectory() as work: @@ -97,15 +97,15 @@ def fire(hook, ev): rc, said = fire('orient.py', {'hook_event_name': 'UserPromptSubmit', 'cwd': repo, 'session_id': 'o2', 'prompt': 'How does Consumer.go work, step by step?'}) check('orient: a how-question is oriented to the flow', rc == 0 and 'next:' in said, said[:300]) - tool_first('orient (how)', said, ('find',)) + tool_first('orient (how)', said, ('path',)) # 5. orient's third hint, for a verb that refuses without a scope: no verb refuses that way today, so it cannot be # fired; the order is checked in the source line that prints it. src = open(os.path.join(HOOKS, 'orient.py'), encoding='utf-8').read() -i = src.find("find(question=\"\") (mcp__plugin_axiomcode_axiomcode__find) ranks") +i = src.find("search these with grep as usual") check('orient (scope refused): its hint is still in the source', i >= 0) if i >= 0: - tool_first('orient (scope refused)', src[i:src.find("')", src.find('`axiomcode find', i))], ('find',)) + tool_first('orient (scope refused)', src[i:src.find("')", src.find('`axiomcode impact', i))], ('impact',)) print(f'\n{len(checked) - len(fails)} of {len(checked)} check(s) held') sys.exit(1 if fails else 0) diff --git a/tests/repo_arg.py b/tests/repo_arg.py index 3a18427b3..006565c61 100644 --- a/tests/repo_arg.py +++ b/tests/repo_arg.py @@ -72,7 +72,7 @@ def no_graph(where, what): seen = [] real, server.run = server.run, (lambda args, *a, **k: seen.append(args) or 'ran') try: - calls = {'find': lambda: server.find('how'), 'path': lambda: server.path('a', 'b'), + calls = {'path': lambda: server.path('a', 'b'), 'impact': lambda: server.impact('foo'), 'tests': lambda: server.tests()} for name, call in calls.items(): seen.clear(); call() diff --git a/tests/surfaces.py b/tests/surfaces.py index ecc8e356d..2ff43816f 100644 --- a/tests/surfaces.py +++ b/tests/surfaces.py @@ -30,7 +30,8 @@ CLI = os.path.join(ROOT, 'bin', 'axiomcode') # the command an install puts on $PATH PUBLIC = ['index', 'find', 'impact', 'path', 'tests'] -NO_MCP = {'index': 'setup, not a question: the first query through the MCP server builds the graph itself'} +NO_MCP = {'index': 'setup, not a question: the first query through the MCP server builds the graph itself', + 'find': 'search is the agent\'s own grep, which a hook annotates; find is a shell verb for a person'} # dispatched, not advertised: verb -> why INTERNAL = { 'build': 'the old name of index', From c8754cb49a4b228efcd0ce9aca326ffed07b5229 Mon Sep 17 00:00:00 2001 From: swapnil Date: Thu, 1 Oct 2026 00:08:09 -0700 Subject: [PATCH 2/2] grep aid: name who enters a declaration nothing in the code calls; tests cover both notes A grep that finds only a route handler's declaration reads as dead code; the note now says the runtime or a framework enters it, and stays silent for a plain uncalled method. The previous note's checks run under AXIOMCODE_GREP_AID=0, and the grep-aid checks (who enters it, silence when grep was complete) run beside them. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- plugins/axiomcode/hooks/enrich.py | 13 +++++++++++- tests/enrich_lines.py | 33 +++++++++++++++++++++++++++++-- tests/hooks_from_path.py | 4 ++++ 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/plugins/axiomcode/hooks/enrich.py b/plugins/axiomcode/hooks/enrich.py index 64b3f04c0..72d36cc9e 100755 --- a/plugins/axiomcode/hooks/enrich.py +++ b/plugins/axiomcode/hooks/enrich.py @@ -182,8 +182,19 @@ def at(ks): if None not in src and not any(WORD(n).search(l) for l in src) and (x['f'], x['ln']) not in hit_lines: hidden.append(f"{x['f']}:{x['ln']} ({x['who'].split('.')[-1]})") hidden = list(dict.fromkeys(hidden)) - if not split and not hidden: continue + # 3. a declaration nothing in the code calls, that the runtime enters (a route, a schedule, a framework + # annotation): grep shows no caller and reads as dead code; this says who calls it. A plain `0` stays unsaid, + # since grep's lines already show it + entered = [] + if not edges: + for d in decls: + is_test = (q("SELECT is_test FROM symbols WHERE method_id = ? LIMIT 1", d['method_id']) or [{'is_test': 0}])[0]['is_test'] + lab = _graphline.zero_label(con, d['method_id'], None, is_test or 0) + if lab.startswith(('entry', '?')) and lab != 'entry (test)': + entered.append(f"{d['display']} {lab.replace('? framework', 'by the framework').replace('entry', 'entered by the runtime')}") + if not split and not hidden and not entered: continue parts = [] + if entered: parts.append("nothing in the code calls " + '; '.join(entered[:3])) if split: parts.append(f"your matches reach different declarations: {split}") if hidden: parts.append(f"{len(hidden)} caller(s) grep cannot see (the line never names it): " + ', '.join(hidden[:4]) + (f" +{len(hidden) - 4}" if len(hidden) > 4 else '')) out.append(f"graph on `{n}`: " + ' | '.join(parts)) diff --git a/tests/enrich_lines.py b/tests/enrich_lines.py index 22bc568a2..27a1dafc1 100644 --- a/tests/enrich_lines.py +++ b/tests/enrich_lines.py @@ -88,11 +88,31 @@ def fire(repo, session, tool, inp, hook='enrich.py', event='PostToolUse'): def grep(repo, pattern, session=None): - return fire(repo, session or f'g-{pattern}', 'Grep', {'pattern': pattern}) + """the previous grep note (declarations with caller counts), still served with AXIOMCODE_GREP_AID=0""" + os.environ['AXIOMCODE_GREP_AID'] = '0' + try: return fire(repo, session or f'g-{pattern}', 'Grep', {'pattern': pattern}) + finally: os.environ.pop('AXIOMCODE_GREP_AID', None) + + +def grep_aid(repo, pattern, session=None): + """the grep note as served: only what the agent's own grep lines cannot show""" + lines = subprocess.run(['git', 'grep', '-nw', pattern], cwd=repo, capture_output=True, text=True).stdout + return _fire_with(repo, session or f'ga-{pattern}', pattern, lines) + + +def _fire_with(repo, session, pattern, lines): + ev = {'hook_event_name': 'PostToolUse', 'tool_name': 'Grep', 'tool_input': {'pattern': pattern, 'output_mode': 'content'}, + 'tool_response': {'mode': 'content', 'content': lines}, 'cwd': repo, 'session_id': session} + r = subprocess.run([sys.executable, os.path.join(HOOKS, 'enrich.py')], input=json.dumps(ev), capture_output=True, text=True, timeout=120) + try: return json.loads(r.stdout)['hookSpecificOutput']['additionalContext'] if r.stdout.strip() else '' + except (ValueError, KeyError, TypeError): return r.stdout.strip() def bash(repo, command, session=None): - return fire(repo, session or f'b-{command}', 'Bash', {'command': command}) + """a grep run through the shell, with the previous note (AXIOMCODE_GREP_AID=0), like grep()""" + os.environ['AXIOMCODE_GREP_AID'] = '0' + try: return fire(repo, session or f'b-{command}', 'Bash', {'command': command}) + finally: os.environ.pop('AXIOMCODE_GREP_AID', None) def line_of(block, name): @@ -121,6 +141,15 @@ def git(repo, *a): '← 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) + # the grep-aid note: grep shows no caller for these; it says who enters them, and nothing for the plain one + for nm, want in (('listOrders', 'entered by the runtime (http)'), ('sweepStale', 'entered by the runtime (scheduled)'), + ('pokeAll', 'by the framework (@RestController on Jobs)')): + a = grep_aid(repo, nm) + check(f'grep aid: {nm}, which nothing in the code calls, says who enters it', want in a, a) + a = grep_aid(repo, 'unusedCount') + check('grep aid control: a method nothing calls and nothing enters adds nothing (grep already shows no caller)', a == '', a) + a = grep_aid(repo, 'findById') + check('grep aid control: a name grep found complete and unambiguous adds nothing', a == '', a) r = fire(repo, 'r1', 'Read', {'file_path': os.path.join(repo, P + 'Ticker.java')}) check('a Read labels a library override the same way', 'run ←? framework (overrides a library method)' in r, r) check('a method of a class with a library base that it may not override names the base', 'tickAll ←? framework (extends Runnable)' in r, r) diff --git a/tests/hooks_from_path.py b/tests/hooks_from_path.py index a52833a3a..229065538 100644 --- a/tests/hooks_from_path.py +++ b/tests/hooks_from_path.py @@ -23,6 +23,10 @@ python3 tests/hooks_from_path.py """ +# this suite checks WHERE a hook finds its graph; the previous grep note always speaks when it does, so it is the proof. +# The grep-aid note is silent whenever grep was complete, which proves nothing about the lookup. +import os as _os +_os.environ['AXIOMCODE_GREP_AID'] = '0' import json, os, shutil, subprocess, sys, tempfile, time # the directive's once-per-session stamp lives in the temp directory, keyed on the session: a run of its own, or a # second run of this script reuses the first run's session ids and hears nothing