From 2f13a969794e42dfc066992fb26bc2ea08fcc1b1 Mon Sep 17 00:00:00 2001 From: swapnil Date: Thu, 1 Oct 2026 13:46:17 -0700 Subject: [PATCH 1/8] index: look up symbols by id and refs and literals by file and line through an index Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index index 10a7bfec..3b6fa191 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index @@ -699,6 +699,9 @@ CREATE INDEX IF NOT EXISTS symbols_name ON symbols(name); CREATE INDEX IF NOT EX CREATE INDEX IF NOT EXISTS symbols_file ON symbols(file, line); CREATE INDEX IF NOT EXISTS symbols_mid ON symbols(method_id); CREATE INDEX IF NOT EXISTS symbols_tid ON symbols(type_id); CREATE INDEX IF NOT EXISTS refs_name ON refs(name); CREATE INDEX IF NOT EXISTS type_refs_name ON type_refs(name); CREATE INDEX IF NOT EXISTS decorations_owner ON decorations(owner_id); CREATE INDEX IF NOT EXISTS literals_value ON literals(value); CREATE INDEX IF NOT EXISTS comments_file ON comments(file); CREATE INDEX IF NOT EXISTS ce_callee ON call_edges(callee_method_id); CREATE INDEX IF NOT EXISTS ce_caller ON call_edges(caller_id); +-- lookups by id and by file + line: without them each per-site or per-test query scans the whole table +CREATE INDEX IF NOT EXISTS symbols_id ON symbols(id); CREATE INDEX IF NOT EXISTS refs_file_line ON refs(file, line); +CREATE INDEX IF NOT EXISTS literals_file_line ON literals(file, line); -- who calls a method: one row per (site, target), with the engine's tier. caller_id is symbols.id: a -- Java call written in a field initializer has the TYPE as its caller, and that row is kept, not dropped CREATE VIEW callers AS From 3b2a584d89557eb01e086c64528aed06cb919b17 Mon Sep 17 00:00:00 2001 From: swapnil Date: Thu, 1 Oct 2026 15:14:46 -0700 Subject: [PATCH 2/8] build: export impact's facts in the background once the graph is queryable Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../axiomcode/skills/axiomcode/scripts/axiomcode-build | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build index 60068b7b..abd5d098 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build @@ -495,12 +495,16 @@ w = max([len('tier')] + [len(t) for t, _ in rows]) print(f"{'tier':<{w}} edges") for t, n in rows: print(f"{t:<{w}} {n}") PY - python3 "$H/axiomcode-impact" --warm "$REPO" || true # export the graph's facts now (~9 s on 1,373 files): the first - # query pays it otherwise, and for the plugin that first query is inside hooks/changes.py's timeout=14, several at once. + # export the graph's facts for impact IN THE BACKGROUND, after the graph is queryable: the first query pays it otherwise + # (and for the plugin that first query is inside hooks/changes.py's timeout=14), but on a large repository the export + # takes minutes and `index` must not wait on it. fd 9 is the build lock: the child closes it, so the lock is released + # when this build ends, and a query or a second build never waits on the warm-up. Its writes are atomic renames. + ( exec 9>&-; nohup python3 "$H/axiomcode-impact" --warm "$REPO" >/dev/null 2>&1 & ) || true + echo "graph ready; precomputing impact facts in the background" # the query rules: their compile started at the top of the build, detached (a caller's timeout cannot kill it half-way, # which cached nothing and repeated on every edit); this only says where it is. A query never waits for it. python3 "$H/dl_program.py" --no-wait || true - [ -n "$BASE_SAVED" ] && AXIOMCODE_GRAPH="$REPO/.axiomcode/base" python3 "$H/axiomcode-impact" --warm "$REPO" >/dev/null 2>&1 || true + [ -n "$BASE_SAVED" ] && ( exec 9>&-; AXIOMCODE_GRAPH="$REPO/.axiomcode/base" nohup python3 "$H/axiomcode-impact" --warm "$REPO" >/dev/null 2>&1 & ) || true [ -z "$OTHER_LANGS" ] || [ ! -s "$PENDING" ] || echo "$LANG_ARG graph ready at $OUT/graph.sqlite; still building: $(cut -d' ' -f1 "$PENDING" | paste -sd, - | sed 's/,/, /g')" } # EVERY OTHER LANGUAGE'S GRAPH, indexed where the engine wrote it and only then moved into place, as the main one is. From f57469283a42c0163482710dbb6a0b592b347530 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:04:22 -0700 Subject: [PATCH 3/8] tests: a failing case prints the tail of its output, where a traceback names the error Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- tests/run.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/run.py b/tests/run.py index 863a7c06..772ba182 100755 --- a/tests/run.py +++ b/tests/run.py @@ -97,7 +97,9 @@ def print(*a, flush=False, **k): builtins.print(*a, file=buf, **k) if bad: fail += 1; print(f"FAIL {l}/{name}: {ch['why']}", flush=True) for b in bad: print(f" missing/unwanted: {b}") - print(' ' + '\n '.join(text.strip().split('\n')[:14])) + lines = text.strip().split('\n') + # a traceback's last line is the error itself: keep the head (what it answered) and the tail (why it stopped) + print(' ' + '\n '.join(lines[:14] + (['…'] + lines[-12:] if len(lines) > 26 else lines[14:]))) elif verbose: print(f"ok {l}/{name}: {ch['why']}") if not keep: shutil.rmtree(os.path.join(path, '.axiomcode'), ignore_errors=True) return buf.getvalue(), tot, fail, pend From d65fd561d3e92943c47a325762b7f1e3dd41c749 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:08:32 -0700 Subject: [PATCH 4/8] context: export the graph's facts before reading its edges index now warms impact's facts in the background, so a query can arrive before edge.facts exists. context never exported them and died on a missing file. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index 99210564..e719552e 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -997,6 +997,9 @@ class G: for r in rows: f.write('\t'.join(str(x) for x in r) + '\n') replace_file(tmp, p) def edges(self): + # exported here, not only by the verbs that call export(): `context` read edge.facts straight after `index` + # returned, while the build's warm-up was still writing it in the background, and died on a missing file + self.export() return [tuple(l.rstrip('\n').split('\t')) for l in open(os.path.join(self.facts, 'edge.facts'))] + list(self.EXTRA) def add_outside_call_edges(self): """THE HOPS NO CALL SITE EXPRESSES, as hops of this query (#1469). A gRPC client reaches the handler that serves From 82a0ab22fa181b98f75d6a4d41357d3f95870087 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:57:03 -0700 Subject: [PATCH 5/8] impact/path: one facts export per graph, however many queries arrive at once The export had no lock: a query that found the stamp stale while another process was already writing the same facts (the background warm-up after index, a second query, several hooks at once) ran the whole export again beside it. Measured on an 8,619-file Java repository: the first query after index cost 193 s against 8.8 s warm, and every concurrent query paid the same again while thrashing the writer. The first comer now takes /.exporting and the others wait for its stamp, then answer from the facts it wrote; a lock whose writer is gone is taken over. tests/export_singleflight.py pins the wait, the takeover and the unlock. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../skills/axiomcode/scripts/axiomcode-impact | 13 ++- .../skills/axiomcode/scripts/axiomcode-path | 47 ++++++++++- tests/export_singleflight.py | 83 +++++++++++++++++++ 3 files changed, 140 insertions(+), 3 deletions(-) create mode 100644 tests/export_singleflight.py diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index e8186697..191cb9f4 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -1275,8 +1275,17 @@ class Impact: # a cache written by an older program, or one an interrupted run left half written, breaks every query with # "Cannot open fact file X.facts": accept it only when it holds every relation the program reads need = set(re.findall(r'\.decl (\w+)\([^)]*\)\s+\.input', open(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'dl', 'impact.dl')).read())) - PER_QUERY - if os.path.exists(stamp) and open(stamp).read() == want and all(os.path.exists(os.path.join(D, n + '.facts')) for n in need): return D - os.makedirs(D, exist_ok=True); W = lambda n, rows: g.write(n, rows, D) + def fresh(): + try: return open(stamp).read() == want and all(os.path.exists(os.path.join(D, n + '.facts')) for n in need) + except OSError: return False + if fresh(): return D + os.makedirs(D, exist_ok=True) + if not P.export_lock(D, fresh): return D # another process just exported these same facts + try: return self._export(D, stamp, want) + finally: P.export_unlock(D) + + def _export(self, D, stamp, want): + g = self.g; W = lambda n, rows: g.write(n, rows, D) # owner display -> type id. A display name is NOT unique: two files of one test suite commonly # declare a class of the same name, and keyed on the name alone the second one's members were # filed under the first one's type. Everything scope/2 reaches then crossed between them -- diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index e719552e..997e29fe 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -128,6 +128,43 @@ def replace_file(tmp, dst): shutil.copyfile(tmp, dst); os.remove(tmp) +def export_lock(d, fresh): + """ONE exporter per facts directory. A query that found the stamp stale while another process (the build's + background warm-up, a concurrent query, several hooks at once) was already writing these facts ran the whole + export AGAIN beside it: on an 8,619-file repository the first query after `index` cost 193 s against 8.8 s + once warm, every concurrent query the same again, and the copies thrashed each other. Returns True with the + lock held — the caller exports, then export_unlock in a finally — or False once another process finished the + same export (`fresh` says so) and the caller just reads it. A lock whose writer is gone is taken over.""" + lock = os.path.join(d, '.exporting') + while True: + try: + fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY) + os.write(fd, str(os.getpid()).encode()); os.close(fd); return True + except FileExistsError: + pass + gone = False + try: + os.kill(int(open(lock).read().strip() or '0'), 0) + except (ProcessLookupError, ValueError): + gone = True + except PermissionError: + pass # alive, another user's process: wait for it + except OSError: # Windows cannot probe another session's pid: fall back to the lock's age + try: gone = time.time() - os.path.getmtime(lock) > 900 + except OSError: continue # the lock vanished under us: contend for it again + if gone: + try: os.remove(lock) + except OSError: pass + continue + time.sleep(0.5) + if fresh(): return False + + +def export_unlock(d): + try: os.remove(os.path.join(d, '.exporting')) + except OSError: pass + + class G: def __init__(self, repo): self.repo = os.path.realpath(repo or '.') @@ -948,8 +985,16 @@ class G: EXPORT_VERSION = '4' def export(self): stamp = os.path.join(self.facts, 'stamp'); want = f"{self.db_mtime}:{self.EXPORT_VERSION}" - if os.path.exists(stamp) and open(stamp).read() == want: return + def fresh(): + try: return open(stamp).read() == want + except OSError: return False + if fresh(): return os.makedirs(self.facts, exist_ok=True) + if not export_lock(self.facts, fresh): return # another process just exported these same facts + try: self._export(stamp, want) + finally: export_unlock(self.facts) + + def _export(self, stamp, want): # `generated` is a client declaration too: a Lombok accessor, a record member, a C# auto-property. The engine # synthesises it and RESOLVES the call sites that name it, so filtering the edge out lost every call into a # generated member — which, on a project whose models are all @Data, is most of what touches the models. diff --git a/tests/export_singleflight.py b/tests/export_singleflight.py new file mode 100644 index 00000000..61ad051e --- /dev/null +++ b/tests/export_singleflight.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""tests/export_singleflight.py — one facts export per graph, however many queries arrive at once. + +The facts a query solves over (out/dl, out/dl/impact) are exported once per graph.sqlite and reused. The export +had no lock: a query that found the stamp stale while another process was already writing the same facts — the +build's background warm-up, a second query, several hooks at once — ran the WHOLE export again beside it. On an +8,619-file repository the first query after `index` cost 193 s against 8.8 s warm, and every concurrent query +paid the same again. Now the first comer takes out/dl/impact/.exporting and the rest wait for its stamp; a lock +whose writer died is taken over. + +Checks, on one indexed fixture: + 1. wait: a lock held by a LIVE process makes a stale --warm wait, and when the holder restores the fresh + stamp and releases, the waiter returns WITHOUT re-exporting (the stamp file is untouched). + 2. takeover: a lock left by a DEAD process does not block — the next --warm removes it and exports. + 3. unlock: a finished export leaves no .exporting behind. + +Indexes one case, so it needs the engine (AXIOMCODE_ENGINE, as tests/run.py). + + python3 tests/export_singleflight.py +""" +import os, shutil, subprocess, sys, tempfile, threading, time + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +AX = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts', 'axiomcode') +IMPACT = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts', 'axiomcode-impact') +CASE = os.path.join(ROOT, 'tests', 'cases', 'java', 'containment-not-from-a-shared-span', 'src') + + +def run(*a, timeout=600): + p = subprocess.run(['bash', AX] + list(a), capture_output=True, text=True, timeout=timeout) + return p.returncode, p.stdout + p.stderr + + +def main(): + fails = [] + def check(ok, why, detail=''): + print(('ok ' if ok else 'FAIL ') + why + ('' if ok else '\n ' + detail.strip().replace('\n', '\n '))) + if not ok: fails.append(why) + + work = tempfile.mkdtemp(prefix='axiomcode-singleflight-') + try: + repo = os.path.join(work, 'repo'); shutil.copytree(CASE, repo) + rc, out = run('index', repo, '--lang', 'java') + if rc: print(f"FAIL index: {out[-300:]}"); return 1 + rc, out = run('impact', '--warm', repo) + check(rc == 0 and 'impact facts ready' in out, 'a first --warm exports the facts', out[-300:]) + D = os.path.join(repo, '.axiomcode', 'out', 'dl', 'impact') + stamp = os.path.join(D, 'stamp'); lock = os.path.join(D, '.exporting') + check(not os.path.exists(lock), 'a finished export leaves no .exporting behind', 'lock file still there') + + # 1. WAIT, DON'T RE-EXPORT: hold the lock as a live process, hide the stamp so the waiter sees stale facts, + # then put the fresh stamp back and release. The waiter must return 0 having written nothing: the stamp's + # mtime is the proof, set well in the past so any rewrite moves it. + held = open(lock, 'w'); held.write(str(os.getpid())); held.flush() + past = time.time() - 3600; os.utime(stamp, (past, past)); before = os.path.getmtime(stamp) + saved = stamp + '.aside'; os.rename(stamp, saved) + + def release(): + time.sleep(2); os.rename(saved, stamp); held.close(); os.remove(lock) + t = threading.Thread(target=release); t.start() + t0 = time.time(); rc, out = run('impact', '--warm', repo, timeout=120); waited = time.time() - t0 + t.join() + check(rc == 0, 'a --warm against a held lock returns 0 once the holder finishes', out[-300:]) + check(waited >= 2, f'it waited for the holder ({waited:.1f}s)', 'returned before the lock was released') + check(os.path.getmtime(stamp) == before, 'and re-exported nothing: the stamp is the one the holder left', + 'stamp mtime moved — the waiter exported over the holder') + + # 2. TAKEOVER: a dead writer's lock does not block. Plant a lock naming a pid that is gone, stale the + # stamp for real, and the next --warm must remove the lock and export. + os.remove(stamp) + open(lock, 'w').write('999999999') + t0 = time.time(); rc, out = run('impact', '--warm', repo, timeout=120) + check(rc == 0 and os.path.exists(stamp), 'a dead writer\'s lock is taken over and the export runs', out[-300:]) + check(not os.path.exists(lock), 'and the taken-over lock is released', 'lock file still there') + finally: + shutil.rmtree(work, ignore_errors=True) + + print(('FAIL: ' + '; '.join(fails)) if fails else 'all export_singleflight checks passed') + return 1 if fails else 0 + + +if __name__ == '__main__': + sys.exit(main()) From 1205b15a157fd6e4bb75e43b43d3db3e7365e730 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:02:33 -0700 Subject: [PATCH 6/8] impact: an undeclared name that arrives through an import hints at --library staging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A name no graph declares, reached only by text, whose lines include an import of it is the signature of an unstaged dependency — until now the answer was indistinguishable from an engine gap, and the fix is one flag away. One hint line under the [text] rows, only for undeclared names. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- plugins/axiomcode/skills/axiomcode/scripts/ax_text.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_text.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_text.py index ada6242b..3ef55ac3 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_text.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_text.py @@ -644,6 +644,13 @@ def block(repo, asked, scope=None, why='unresolved', rows=ROWS): if rest > 0: lines.append(f" … +{rest} more line(s): git grep -n{'w' if w else ''} -F -e '{n}'" if not pat else f" … +{rest} more line(s): git grep -nP -e '{pat.pattern}'") + # a name no graph declares that ARRIVES THROUGH AN IMPORT is the signature of an unstaged dependency, the + # commonest setup gap there is: without the hint this answer is indistinguishable from an engine gap, and the + # fix (stage the dependency's source) is one flag away. Only for undeclared names: a string or a resolved + # target wants no staging advice. + if why == 'undeclared' and any(re.match(r'\s*(import\b|using\b|from\s)', t.strip()) and n in t for _f, _l, t in hits): + lines.append(f" this name arrives through an import, so it is likely declared in a dependency: calls through " + f"it resolve once that dependency's source is staged — `axiomcode index --library `") if not lines: return '' if approxed: lines.append("next: [approx] rows are text placed in the declaration that holds it, not resolved edges: read the " "evidence line, then `impact ` for what a change reaches") From 6dde0de526b3ce290b15f5e83c2ffe643fe9db97 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:50:06 -0700 Subject: [PATCH 7/8] =?UTF-8?q?impact/path:=20the=20facts=20export=20stops?= =?UTF-8?q?=20paying=20per-row=20SQL=20=E2=80=94=20171=20s=20to=2055=20s?= =?UTF-8?q?=20on=208,619=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three wastes, found by timing each exported relation and profiling the poles standalone: - site_file() ran one has() and one paths lookup PER CALL, and the export calls it for every call edge: 638k round-trips over 319k edges. The paths map is now read once per graph (171 s -> 89 s). - via_base_rows probes field_access per single-target call site, and the table had no caller_id index: a 65,797-row scan 5,162 times. fa_caller lands with the other build-time indexes (89 s -> 63 s). - registrations() was computed twice in one export, once for the registration relation and again inside reg_key_fact (63 s -> 55 s). Every fact file is content-identical before and after (two differ in row order only, written from unsorted sets before this change too). Tests: java 329/329, python 287/287, facts_cache, latency, freshness, export_singleflight, front_door. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../skills/axiomcode/scripts/axiomcode-impact | 5 +++-- .../skills/axiomcode/scripts/axiomcode-index | 3 +++ .../skills/axiomcode/scripts/axiomcode-path | 14 ++++++++++---- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 191cb9f4..609a53b7 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -1517,7 +1517,8 @@ class Impact: L = self.code(r['file']); text = L[r['line'] - 1] if r['line'] <= len(L) else '' for qn in re.findall(rf'([A-Za-z_$][\w$]*)\s*\.\s*{re.escape(r["name"])}\b', text): quals.append((r['file'], r['line'], r['name'], qn)) W('ref', refs); W('qualifier', sorted(set(quals))) - W('registration', ax_registration.registrations(g.q, g.site_file)) + regs = ax_registration.registrations(g.q, g.site_file) # computed once: reg_key_fact below reads it too + W('registration', regs) trs = []; trf = set() # a reference on a module-level type alias's own lines belongs to the alias, not the module (#784) import graph_sql @@ -1599,7 +1600,7 @@ class Impact: regk = [] # `rd` and not `decl`: `decl` is the dec_literal list above for rd, _f, _l, kind, key, _why in (ax_registration.decoration_keys(g.q, g.site_file) + ax_registration.value_route_registrations(g.q, g.site_file) - + ax_registration.registrations(g.q, g.site_file) + tregs): + + regs + tregs): if rd in g.sym and key: regk.append((rd, kind, key)) W('reg_key_fact', sorted(set(regk))) # the callables in test files: a test that publishes a handler table's key drives the handler, and is not diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index index 3b6fa191..19c50a44 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index @@ -702,6 +702,9 @@ CREATE INDEX IF NOT EXISTS ce_callee ON call_edges(callee_method_id); CREATE IND -- lookups by id and by file + line: without them each per-site or per-test query scans the whole table CREATE INDEX IF NOT EXISTS symbols_id ON symbols(id); CREATE INDEX IF NOT EXISTS refs_file_line ON refs(file, line); CREATE INDEX IF NOT EXISTS literals_file_line ON literals(file, line); +-- the fields a caller reads: via_base_rows asks per single-target call site, and without the index each ask +-- scanned the whole table (65,797 rows × 5,162 sites on an 8,619-file repository = 24 s of a 171 s warm → 1.4 s) +CREATE INDEX IF NOT EXISTS fa_caller ON field_access(caller_id); -- who calls a method: one row per (site, target), with the engine's tier. caller_id is symbols.id: a -- Java call written in a field initializer has the TYPE as its caller, and that row is kept, not dropped CREATE VIEW callers AS diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index 997e29fe..55b02956 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -969,10 +969,16 @@ class G: unfoll = [(r['lib'], r['n']) for r in rows if r['from_site']][:cap] return named, unfoll def site_file(self, raw): - """a call site's file as the repo sees it: Java bundles store absolute paths; the index's paths table maps them""" - if self.has('paths'): - r = self.q("SELECT rel FROM paths WHERE raw = ?", raw) - if r: return r[0]['rel'] + """a call site's file as the repo sees it: Java bundles store absolute paths; the index's paths table maps them. + The map is read ONCE PER GRAPH, not once per row: this ran a query per call (one `has`, one lookup), and the + facts export calls it for every call edge — 319k edges on an 8,619-file repository made 638k round-trips + through sqlite, which was most of the export's time (reg_key_fact 36 s, calls 34 s, registration 31 s, + via_base 25 s of a 171 s warm).""" + m = getattr(self, '_site_files', None) + if m is None: + m = self._site_files = {r['raw']: r['rel'] for r in self.q("SELECT raw, rel FROM paths")} if self.has('paths') else {} + got = m.get(raw) + if got is not None: return got return os.path.relpath(raw, self.repo).replace(os.sep, '/') if os.path.isabs(raw) and raw.startswith(self.repo) else raw # ── facts: exported once per graph, reused while graph.sqlite is unchanged ─────────────────────────────── From 8ba21f17df71dbdedfd80050fe7b8df8fd361e68 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:23:38 -0700 Subject: [PATCH 8/8] The MCP surface is impact, path and tests: search is the agent's own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit find(question) is no longer offered as an MCP tool or advertised on any agent-facing surface (SKILL.md, AGENTS.md, the rules file, the install block, the CLI help, the hooks' hints). Search belongs to the agent; the graph's job is what search cannot do — callers, blast radius, call chains, test selection — and the hooks keep feeding that in where a search falls short. The verb stays dispatched (find is context underneath) for the hooks, the suites and anyone who already calls it; it is internal now, not public. The orientation hook's two find hints become impact and path hints, and the pre-search directive drops its find clause. The suites that pin the surface (surfaces, mcp, mcp_docs, mcp_first, manifests, front_door, repo_arg, freshness) pin the three-tool surface instead. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> Co-Authored-By: Claude Fable 5 --- README.md | 6 +++--- bin/axiomcode | 2 +- plugins/axiomcode/AGENTS.md | 7 ++++--- plugins/axiomcode/hooks/direct.py | 3 +-- plugins/axiomcode/hooks/orient.py | 12 ++++++------ plugins/axiomcode/mcp/server.py | 10 ++-------- plugins/axiomcode/rules/axiomcode.mdc | 7 ++++--- plugins/axiomcode/skills/axiomcode/SKILL.md | 16 +++++----------- .../axiomcode/skills/axiomcode/scripts/axiomcode | 8 +++----- .../skills/axiomcode/scripts/axiomcode-install | 5 +++-- skills/axiomcode/SKILL.md | 16 +++++----------- tests/freshness.py | 6 +++--- tests/front_door.py | 8 ++++---- tests/manifests.py | 2 +- tests/mcp.py | 11 +++++------ tests/mcp_docs.py | 12 ++++++------ tests/mcp_first.py | 12 ++++++------ tests/repo_arg.py | 2 +- tests/surfaces.py | 5 +++-- 19 files changed, 66 insertions(+), 84 deletions(-) diff --git a/README.md b/README.md index 166aaf3d..2ff85549 100644 --- a/README.md +++ b/README.md @@ -308,12 +308,12 @@ about one change had to read 95 of 43,793 methods, and every true direct caller ## CLI commands -Four questions, each answered as numbered places with the code of the function each one sits in. The MCP server -offers the same four as tools: `find(question)`, `impact(name)`, `path(start, end)` and `tests()`. +Three questions, each answered as numbered places with the code of the function each one sits in. The MCP server +offers the same three as tools: `impact(name)`, `path(start, end)` and `tests()`. Finding where code lives is +left to your own search: bring the name you found to these commands. | command | what it answers | |---|---| -| `axiomcode find ""` | where the code for a task lives, when you have it in words and not yet a name | | `axiomcode impact ` | who calls it, what a change to it reaches, and the tests that exercise it | | `axiomcode impact` | the same for the declarations your uncommitted edits changed; the answer starts with `your edits:` | | `axiomcode path ` | how A reaches B: every hop of the call chain, with the code at each call | diff --git a/bin/axiomcode b/bin/axiomcode index 6b902cf0..873252f4 100755 --- a/bin/axiomcode +++ b/bin/axiomcode @@ -1,7 +1,7 @@ #!/usr/bin/env bash # ───────────────────────────────────────────────────────────────────────────── # axiomcode — ask a repository's call graph. `axiomcode --help` prints the dispatcher's help -# (plugins/axiomcode/skills/axiomcode/scripts/axiomcode): index, find, impact, path and tests. +# (plugins/axiomcode/skills/axiomcode/scripts/axiomcode): index, impact, path and tests. # ───────────────────────────────────────────────────────────────────────────── # INTERNAL COMMANDS, not advertised: the build, the engine suites and the MCP server, which # the dispatcher, the test suites and the agent manifests call. diff --git a/plugins/axiomcode/AGENTS.md b/plugins/axiomcode/AGENTS.md index ac48bfde..1da578d8 100644 --- a/plugins/axiomcode/AGENTS.md +++ b/plugins/axiomcode/AGENTS.md @@ -1,17 +1,18 @@ # axiomcode -For any why, what or where question about code — where something lives, who calls it, what a change +For any question about how code connects — who calls it, what a change breaks, which tests an edit reaches — ask the repository's call graph FIRST, through the axiomcode MCP tools: - 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`. +Finding where code lives is yours: search as you normally would, then bring the name you found to these tools. + Every answer is a numbered list of places, each with the code of the function it sits in and the line that matters marked `→`: answer from that code, and open a file only where a body was cut. A `resolved` place has already been re-checked in the graph (the `verified:` line); do not re-derive it by grepping. `by name` / diff --git a/plugins/axiomcode/hooks/direct.py b/plugins/axiomcode/hooks/direct.py index 7a4285b8..58ca74d4 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/orient.py b/plugins/axiomcode/hooks/orient.py index 316cfc7b..1fcddcf5 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 under these for a name, then impact(name="") (mcp__plugin_axiomcode_axiomcode__impact) returns ' + 'who calls it and what a change reaches, each with its code; call it directly, no skill needs loading first ' + '(without that tool: `axiomcode impact `).') else: print("graph: where this task's own words land in the index —") for l in lines[:MAX_LINES]: @@ -210,9 +210,9 @@ 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: path(start="", end="") (mcp__plugin_axiomcode_axiomcode__path) returns every hop ' + 'of the call chain between two of these, each with its code; call it directly, no skill needs loading ' + 'first (without that tool: `axiomcode path `).') 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 2f30e142..74d327b8 100755 --- a/plugins/axiomcode/mcp/server.py +++ b/plugins/axiomcode/mcp/server.py @@ -300,7 +300,7 @@ def _doc(f): f.__doc__ = (f.__doc__ or '') + EV_DOC return f -# THE FOUR TOOLS TAKE NO OPTIONS, so an answer never tells the agent to pass one. The notes the verbs add (a stale +# THE THREE TOOLS TAKE NO OPTIONS, so an answer never tells the agent to pass one. The notes the verbs add (a stale # graph, a refresh in flight) are kept for what they say; a clause that names a flag or a parameter to set is dropped. _OPTION = re.compile(r"(? 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()])) - +# Finding WHERE code lives is left to the agent's own search; the hooks feed the graph in where the search falls short. @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 d77c4ea5..e3c5a549 100644 --- a/plugins/axiomcode/rules/axiomcode.mdc +++ b/plugins/axiomcode/rules/axiomcode.mdc @@ -5,18 +5,19 @@ alwaysApply: true # axiomcode -For any why, what or where question about code — where something lives, who calls it, what a change +For any question about how code connects — who calls it, what a change breaks, which tests an edit reaches — ask the repository's call graph FIRST, through the axiomcode MCP tools: - 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`. +Finding where code lives is yours: search as you normally would, then bring the name you found to these tools. + Every answer is a numbered list of places, each with the code of the function it sits in and the line that matters marked `→`: answer from that code, and open a file only where a body was cut. A `resolved` place has already been re-checked in the graph (the `verified:` line); do not re-derive it by grepping. `by name` / diff --git a/plugins/axiomcode/skills/axiomcode/SKILL.md b/plugins/axiomcode/skills/axiomcode/SKILL.md index 3628a493..a9934b2f 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 question about how code connects — who calls it, what a change to it breaks, how one piece reaches another, which tests cover an edit. Examples: "What calls this?", "What breaks if I change Z?", "How does A reach B?", "Which tests do I run?". Mandatory when .axiomcode/out/graph.sqlite exists — ask it before reasoning about callers or blast radius, 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. Finding where code lives is yours: search as you normally would, then bring the name you found here. 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 -`/scripts/axiomcode ` from the repository root. Same answer either way. +Three 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__impact`, `__path`, `__tests`); otherwise run +`/scripts/axiomcode ` from the repository root. Same answer either way. Finding where code lives +is yours: search as you normally would, then bring the name you found here. | the question | MCP tool | shell | |---|---|---| -| where is the code for this task? | `find(question)` | `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 ` | @@ -40,12 +40,6 @@ is: `resolved` is an edge the engine resolved and re-checked (`verified:`), do n `hop N` is how far out it is. A call the graph could not resolve is *unknown*, not absent: never report "no callers" from an empty answer. -## find - -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")`. - ## impact With a name: who calls it, what depends on it further out, and the tests that exercise it. Example: diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode index cfcaf9ff..445a508d 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode @@ -2,8 +2,6 @@ # axiomcode — ask the repository's call graph. Each answer is a numbered list of places, each with the code of the # function it sits in. # -# axiomcode find "" -# where the code for a task lives: the functions involved, most relevant first. # axiomcode impact [] # who calls it, what a change to it reaches, and the tests that exercise it. # With no name: the same for the declarations your uncommitted edits changed. @@ -133,7 +131,7 @@ helptext(){ awk 'NR>1 && /^#/ {sub(/^# ?/, ""); print; next} NR>1 {exit}' "$0"; # leading comment block. The verb documents itself once, where it is implemented. verbhelp(){ # a verb of the small surface is explained by its own entry in the help above: what it answers, no options - case "$1" in find|impact|path|tests) + case "$1" in impact|path|tests) helptext | awk -v v="$1" '$0 ~ "^ axiomcode "v"( |$)" {on=1; print; next} on && /^ axiomcode / {exit} on && /^$/ {exit} on {print}' return 0 ;; esac @@ -218,9 +216,9 @@ done # shapes are one answer. Without it the answer is the verb's own, unchanged. G=() case "$cmd" in context|path|impact|test-impact|tests) [ -n "${GREP:-}" ] && G=(python3 "$H/ax_grep.py" "$cmd" "$FR" --limit "${GREP_LIMIT:-30}" --) ;; esac -# THE SMALL SURFACE: find, impact, path and tests, asked with no flags at the front door (the installed `axiomcode` and +# THE SMALL SURFACE: impact, path and tests, asked with no flags at the front door (the installed `axiomcode` and # the MCP server set AXIOMCODE_FRONT), answer as numbered places, each with the code of the function it sits in -# (ax_blocks.py), so a place needs no read to be understood. find is context; impact is impact with its tests, +# (ax_blocks.py), so a place needs no read to be understood. impact is impact with its tests, # and impact with no name answers for the declarations the working tree has edited; path is path; tests is # test-impact. A flag, AXIOMCODE_RAW, or a caller that runs this script directly (the hooks, the suites) gets the # verb's own answer. diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-install b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-install index c562c060..339a41b7 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-install +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-install @@ -38,15 +38,16 @@ BLOCK = """ This repository has a resolved call graph. Ask it FIRST, 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 tests() # the tests your edits reach, and how to run them -Only when those tools are not in your list, the same from the shell: `axiomcode find ""`, +Only when those tools are not in your list, the same from the shell: `axiomcode impact `, `axiomcode path `, `axiomcode tests`. +Finding where code lives is yours: search as you normally would, then bring the name you found here. + **Trust the answer.** Each place comes with the code of the function it sits in: answer from it. A `resolved` place has already been looked up again in the graph (the `verified:` line) — do not re-derive it by grepping. `by name` / `text` places are leads, not facts. An unresolved call means *unknown*, not *absent*. diff --git a/skills/axiomcode/SKILL.md b/skills/axiomcode/SKILL.md index ff9eb64f..c6bc0277 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 question about how code connects — who calls it, what a change to it breaks, how one piece reaches another, which tests cover an edit. Examples: "What calls this?", "What breaks if I change Z?", "How does A reach B?", "Which tests do I run?". Mandatory when .axiomcode/out/graph.sqlite exists — ask it before reasoning about callers or blast radius, 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. Finding where code lives is yours: search as you normally would, then bring the name you found here. 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 -`/../../plugins/axiomcode/skills/axiomcode/scripts/axiomcode ` from the repository root. Same answer either way. +Three 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__impact`, `__path`, `__tests`); otherwise run +`/../../plugins/axiomcode/skills/axiomcode/scripts/axiomcode ` from the repository root. Same answer either way. Finding where code lives +is yours: search as you normally would, then bring the name you found here. | the question | MCP tool | shell | |---|---|---| -| where is the code for this task? | `find(question)` | `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 ` | @@ -40,12 +40,6 @@ is: `resolved` is an edge the engine resolved and re-checked (`verified:`), do n `hop N` is how far out it is. A call the graph could not resolve is *unknown*, not absent: never report "no callers" from an empty answer. -## find - -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")`. - ## impact With a name: who calls it, what depends on it further out, and the tests that exercise it. Example: diff --git a/tests/freshness.py b/tests/freshness.py index 08bcd425..819e0a44 100644 --- a/tests/freshness.py +++ b/tests/freshness.py @@ -824,13 +824,13 @@ def mcp_checks(): with contextlib.redirect_stderr(io.StringIO()): spec.loader.exec_module(m) # THE SMALL SURFACE takes no options: freshness is the dispatcher's own (a query waits briefly, or answers from the # last graph and says so), so no tool takes fresh or refresh, and none passes --fresh or --no-refresh - tools = ('find', 'impact', 'path', 'tests') + tools = ('impact', 'path', 'tests') check("mcp: no tool takes fresh or refresh", not any(p in m.PARAMS.get(t, []) for t in tools for p in ('fresh', 'refresh')) and all(t in m.PARAMS for t in tools), {t: m.PARAMS.get(t) for t in tools}) seen = [] m.run = lambda args, *a, **k: seen.append(args) or '' - m.find('t'); m.impact('X'); m.impact(); m.path('A', 'B'); m.tests() - check("mcp: no tool passes --fresh or --no-refresh", len(seen) == 5 and not any(a in s for s in seen for a in ('--fresh', '--no-refresh')), seen) + m.impact('X'); m.impact(); m.path('A', 'B'); m.tests() + check("mcp: no tool passes --fresh or --no-refresh", len(seen) == 4 and not any(a in s for s in seen for a in ('--fresh', '--no-refresh')), seen) check("mcp: fresh=true is refused as an unknown argument, not dropped", 'fresh: unexpected argument' in (m.unknown_arguments('impact', {'name': 'X', 'fresh': True}) or ''), m.unknown_arguments('impact', {'name': 'X', 'fresh': True})) diff --git a/tests/front_door.py b/tests/front_door.py index 73f2d034..c9471cf2 100644 --- a/tests/front_door.py +++ b/tests/front_door.py @@ -118,13 +118,13 @@ def main(): 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, [('impact', {'name': 'vat_rate'}), ('path', {'start': 'invoice', 'end': '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', 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 impact answers as numbered places with their code', places(text(3)) and 'shop/pricing.py:6' in text(3), text(3)[:600]) + check('MCP path answers as numbered places with their code', places(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/manifests.py b/tests/manifests.py index 24310091..59a15329 100644 --- a/tests/manifests.py +++ b/tests/manifests.py @@ -172,7 +172,7 @@ def gemini_path(value): if re.search(r'(?-\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("impact(name=\"\") (mcp__plugin_axiomcode_axiomcode__impact) returns") 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 3a18427b..006565c6 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 ecc8e356..a0005a88 100644 --- a/tests/surfaces.py +++ b/tests/surfaces.py @@ -29,12 +29,13 @@ MCP = os.path.join(PLUG, 'mcp', 'server.py') CLI = os.path.join(ROOT, 'bin', 'axiomcode') # the command an install puts on $PATH -PUBLIC = ['index', 'find', 'impact', 'path', 'tests'] +PUBLIC = ['index', 'impact', 'path', 'tests'] NO_MCP = {'index': 'setup, not a question: the first query through the MCP server builds the graph itself'} # dispatched, not advertised: verb -> why INTERNAL = { 'build': 'the old name of index', - 'context': 'what find runs; its flags (--in, --source, --from, …) serve the hooks and the suites', + 'context': 'search by task words; search is the agent\'s own, the hooks feed the graph in where it falls short', + 'find': 'the front-door spelling of context; dispatched for compatibility, no longer advertised', 'changed': 'impact with no name answers the same question at the front door; the edit hooks read it with --json', 'test-impact': 'what tests runs; its flags (--range, --staged, --why, …) serve scripts and the suites', 'graph': 'draws the graph as a page for a person; not one of the four questions',