diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 3c675ed1..cb6710f5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -253,7 +253,7 @@ jobs:
needs: [changes]
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-24.04
- timeout-minutes: 45
+ timeout-minutes: 90
env:
# the engine is compiled for any x86-64 runner, so a cached one can be restored
# on whichever runner this job lands (see the engine cache step below)
@@ -448,6 +448,25 @@ jobs:
# AXIOM_SUITE_JOBS=1 here would run them one at a time, as they used to.
run: bash .github/scripts/run-suite.sh ${{ matrix.lang }} ${{ matrix.oracle }}
+ # THE QUERY LAYER'S CASES (tests/run.py): what impact, path, context, changed and test-impact answer on small
+ # projects written for one behaviour each. Every case must pass, and a case marked pending that now passes fails
+ # the run until the mark is removed, so a fix for one shape cannot quietly break another's answer. The suite
+ # calls the verbs directly, so it checks their own answers, not the front-door rendering (tests/front_door.py).
+ - name: ${{ matrix.lang }} query cases
+ env:
+ AXIOM_PARSER: ${{ github.workspace }}/parser/dist/index.js
+ AXIOM_SOUFFLE_CACHE: ${{ github.workspace }}/.souffle-cache
+ run: python3 tests/run.py --lang ${{ matrix.lang }} --jobs 4
+
+ # the small surface as users and agents get it: find / impact / path / tests through the installed command and
+ # the MCP server, answered as places with their code, and the direct calls and flags that keep the old answers
+ - name: the front door answers as places with their code
+ if: matrix.lang == 'python'
+ env:
+ AXIOM_PARSER: ${{ github.workspace }}/parser/dist/index.js
+ AXIOM_SOUFFLE_CACHE: ${{ github.workspace }}/.souffle-cache
+ run: python3 tests/front_door.py
+
# The hooks answer impact from SQL (graph_sql.impact_shaped) and fall back to the rules (dl/impact.dl) only when
# it declines, so the two must list the same rows for the same edit: tests/fastpath.py indexes a small case, asks
# both on each target shape, and runs hooks/changes.py on one edit through each path. Same parser and engine
diff --git a/README.md b/README.md
index 23ca5395..166aaf3d 100644
--- a/README.md
+++ b/README.md
@@ -100,7 +100,7 @@ That matters because an agent follows edges several hops deep, and one missed li
.*?)(?:\s+\[(?P[^\]]*)\])?$')
+
+
+class Graphs:
+ """the enclosing callable of a line, from every graph the repository holds (one per language)"""
+ def __init__(self, repo):
+ out = os.path.join(repo, '.axiomcode', 'out')
+ self.cons = []
+ for d in sorted(os.listdir(out)) if os.path.isdir(out) else []:
+ p = os.path.join(out, d, 'graph.sqlite')
+ if os.path.isfile(p):
+ try: self.cons.append(sqlite3.connect(f"file:{p}?mode=ro", uri=True))
+ except sqlite3.Error: pass
+
+ def enclosing(self, f, n):
+ best = None
+ for c in self.cons:
+ try:
+ r = c.execute("SELECT display, line, end_line FROM symbols WHERE (file = ? OR file LIKE ?) AND line <= ? AND end_line >= ? "
+ "AND method_id IS NOT NULL AND display NOT LIKE '%%' ORDER BY end_line - line LIMIT 1",
+ (f, '%/' + f, n, n)).fetchone()
+ except sqlite3.Error:
+ continue
+ if r and (best is None or r[2] - r[1] < best[2] - best[1]): best = r
+ return best
+
+
+def block(repo, f, marks, span):
+ """the lines to show for the marked lines of file f: the enclosing callable (whole, or header + a window around
+ each mark), numbered, every mark flagged"""
+ try:
+ with open(os.path.join(repo, f), encoding='utf-8', errors='replace') as h: L = h.read().split('\n')
+ except OSError:
+ return []
+ marks = sorted(n for n in marks if 0 < n <= len(L))
+ if not marks: return []
+ lo, hi = (span[1], span[2]) if span else (marks[0], marks[-1])
+ lo, hi = max(1, min(lo, marks[0])), min(len(L), max(hi, marks[-1]))
+ if hi - lo + 1 <= WHOLE:
+ keep = list(range(lo, hi + 1))
+ else:
+ keep = {lo}
+ for n in marks: keep |= set(range(max(lo, n - AROUND), min(hi, n + AROUND) + 1))
+ keep = sorted(keep)
+ w = len(str(keep[-1])); out = []; prev = None
+ for i in keep:
+ if prev is not None and i != prev + 1: out.append(' ' * (w + 4) + '…')
+ out.append(f"{'→' if i in marks else ' '} {str(i).rjust(w)} {L[i - 1].rstrip()}")
+ prev = i
+ return out
+
+
+# rows that add nothing an agent acts on: a word match offered only because nothing better was found (dropped when a
+# better row exists), and a module's own scope (its import lines)
+FILLER = 'best overall match'
+NOISE = ('module scope',)
+
+
+def render(verb, doc, repo):
+ code = ax_grep.Code(repo)
+ rows, _rest, foot = ax_grep.VERBS[{'find': 'context', 'tests': 'test-impact'}.get(verb, verb)](doc, code)
+ sites = []
+ for _k, line in rows:
+ m = SITE.match(line)
+ if m: sites.append((m.group('file'), int(m.group('line')), m.group('tag') or ''))
+ sites = [x for x in sites if not any(w in x[2] for w in NOISE)]
+ if any(FILLER not in t for _f, _n, t in sites): sites = [x for x in sites if FILLER not in x[2]]
+ # ONE PLACE PER FUNCTION: two relevant lines of one function are one block with both marked, in the order the
+ # verb ranked the first of them
+ graphs = Graphs(repo); places = {}
+ for f, n, t in sites:
+ span = graphs.enclosing(f, n)
+ key = (f, span[1], span[2]) if span else (f, n, n)
+ p = places.setdefault(key, {'f': f, 'span': span, 'marks': [], 'tags': []})
+ 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)
+ out = []
+ for i, p in enumerate(list(places.values())[:CAP], 1):
+ where = f"{p['f']}:{','.join(map(str, sorted(p['marks'])))}"
+ out.append(f"{i}. {where}" + (f" [{' | '.join(p['tags'][:2])}]" if p['tags'] else ''))
+ body = block(repo, p['f'], p['marks'], p['span'])
+ if body:
+ out.append(f" ```{FENCE.get(os.path.splitext(p['f'])[1], '')}")
+ out += [' ' + b for b in body]
+ out.append(' ```')
+ 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 verb_json(cmd):
+ import ax_exec
+ r = subprocess.run(ax_exec.program(cmd + ['--json']), stdout=subprocess.PIPE, text=True, encoding='utf-8', errors='replace')
+ try: doc = json.loads(r.stdout)
+ except ValueError: doc = None
+ return r, doc
+
+
+def edits(repo):
+ """impact with no name: what the working tree's edits changed, then what depends on those declarations, with code"""
+ r, doc = verb_json(['python3', os.path.join(H, 'axiomcode-changed'), repo])
+ if not isinstance(doc, dict):
+ sys.stdout.write(r.stdout); return r.returncode
+ ch = doc.get('changed') or []
+ targets = list(dict.fromkeys(c['target'] for c in ch if c.get('target')))
+ head = ["your edits: " + (', '.join(f"{c.get('kind')} {c.get('shown_target') or c.get('symbol')}" for c in ch[:8]) or 'none')
+ + (f" (+{len(ch) - 8} more)" if len(ch) > 8 else '')]
+ if not targets:
+ print('\n'.join(head + ["nothing edited is a declaration other code depends on" if ch else
+ "no edits against the commit the graph was built from"]))
+ return 0
+ r, doc = verb_json(['python3', os.path.join(H, 'axiomcode-impact')] + targets + [repo, '--tests'])
+ lines = render('impact', doc, repo) if isinstance(doc, dict) else None
+ print('\n'.join(head + (lines or [x for x in (doc or {}).get('prose', [])] or [r.stdout.strip()])))
+ return 0
+
+
+def main(argv):
+ verb, repo = argv[0], argv[1]
+ if verb == 'edits': return edits(repo)
+ cmd = argv[3:] if len(argv) > 2 and argv[2] == '--' else argv[2:]
+ r, doc = verb_json(cmd)
+ if not isinstance(doc, dict):
+ sys.stdout.write(r.stdout); return r.returncode
+ lines = render(verb, doc, repo) if r.returncode in (0, 1) or doc.get('called_undeclared') else None
+ if lines is None:
+ # a refusal or an answer with no place in it: the verb's own words are the answer
+ print('\n'.join(doc.get('prose') or []) or r.stdout.strip()); return r.returncode
+ print('\n'.join(lines))
+ return 0
+
+
+if __name__ == '__main__':
+ if len(sys.argv) < 3 or (sys.argv[1] != 'edits' and len(sys.argv) < 4): sys.exit(__doc__)
+ sys.exit(main(sys.argv[1:]))
diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py
index c0d82607..2f892c57 100644
--- a/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py
+++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py
@@ -16,6 +16,7 @@
here: the reference alone says it is passed as a value (`valueref` in dl/impact.dl), and naming the receiving call as
one that "calls it where the graph cannot follow" was wrong for every synchronous collection operation (#1166).
"""
+import collections
import re
ROUTE_VERB = {'get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace', 'connect', 'all', 'use', 'route'}
@@ -990,8 +991,150 @@ def route_candidates(written, registered_keys):
def all_registrations(q, site_file=None):
- """Every (decl, file, line, kind, key, why) this module can derive, from all three sources."""
- return sorted(set(registrations(q, site_file) + decoration_keys(q, site_file) + value_route_registrations(q, site_file)))
+ """Every (decl, file, line, kind, key, why) this module can derive, from all four sources."""
+ return sorted(set(registrations(q, site_file) + decoration_keys(q, site_file) + value_route_registrations(q, site_file)
+ + table_registrations(q, site_file)))
+
+
+# ── a HANDLER TABLE: a declaration registered under the KEY of the entry that holds it ──────────────────────────
+# One service publishes an event by its type (`bus.publish(TOPICS.CREATED, doc)`, `emit("doc.created", …)`); another
+# holds a table of handlers keyed by the same string (`{ [TOPICS.CREATED]: onCreated }`, `{ 'doc.created'(env) {…} }`,
+# `{"doc.created": on_created}`) and a consumer looks the handler up by the message's type (`handlers[type](env)`).
+# The graph has both ends and the lookup is a computed member, so nothing joined the publisher to the handler: impact
+# of the producing method missed every consumer, and the tests that publish the type never reached the handler.
+# It is a registration like a route: the entry's key is what the dispatcher dispatches on. Two facts are read here:
+# the table entry a callable DECLARED on the entry's own line, right after its key (`[K]: function …`, `[K]: (e) =>`,
+# `'k'(e) {`, `"k": lambda e: …`), or a callable NAMED as the entry's whole value (`[K]: onCreated,`).
+# A key is a string literal or a constant reference resolved to one; an entry whose value is an
+# array, a call's result or a schema is data, not a handler, and registers nothing.
+# the constant `TOPICS.CREATED` is the string its declaration gives it (`export const TOPICS = { CREATED:
+# 'doc.created' }`, `class Topics: CREATED = "doc.created"`, `static final String CREATED = …`),
+# kept only when every declaration of that name agrees. A key written through a constant is a
+# write of the string; the constant's own declaration and another table's key position are not.
+_IDENT = r'[A-Za-z_$][\w$]*'
+_CONST_REF = rf'{_IDENT}(?:\.{_IDENT})+'
+_KEY_STR = r"""(?P['"])([^'"\\\s]{1,120})(?P=qt)""" # named: its group number differs in each pattern
+# the start of a function value: `function`, `async (e) =>`, `e =>`, `(e) =>`, a Python `lambda`
+_FN_START = rf'(?:async\s+)?(?:function\b|lambda\b|\(|{_IDENT}\s*=>)'
+# the key of an entry that DECLARES its handler on this line: `[K]: `, `[K](…) {`, `'k': `, `'k'(…) {`, and a
+# Python dict's `Topics.K: lambda …`. Method shorthand may carry `async` / `static` / `*`.
+_ENTRY_DECL = re.compile(rf"""^\s*(?:(?:async|static|get|set)\s+|\*\s*)*(?:(?:\[\s*({_CONST_REF}|{_IDENT})\s*\]|{_KEY_STR})\s*(?::\s*{_FN_START}|\()|({_CONST_REF})\s*:\s*{_FN_START})""")
+# an entry whose WHOLE value names a handler: `[K]: onCreated,` / `'k': handlers.onCreated,` / `"k": on_created,`
+_ENTRY_REF = re.compile(rf"""^\s*(?:\[\s*({_CONST_REF}|{_IDENT})\s*\]|{_KEY_STR}|({_CONST_REF}))\s*:\s*(?:this\.|self\.)?({_IDENT}(?:\.{_IDENT})*)\s*,?\s*(?:\}}\s*[,;)]*\s*)?$""")
+# a string constant: an object literal's `K: 'v'` (one per line or several on one), and a declaration `K = 'v'`
+_CONST_ENTRY = re.compile(rf"""(?:^|[{{,])\s*({_IDENT})\s*:\s*{_KEY_STR}\s*(?=,|\}}|$)""")
+_CONST_DECL = re.compile(rf"""(?:^|\s)({_IDENT})\s*(?::\s*[\w.<>\[\]]+\s*)?=\s*{_KEY_STR}\s*[;,]?\s*$""")
+# a key POSITION, not a write: the quoted key or the constant is followed by `:` (an entry, a `case`), `(` (a method
+# shorthand) or `]` and then `:` / `(` / `=` (a computed key, a C# index initializer)
+_KEY_POS = re.compile(r'\s*(?::(?!:)|\(|\]\s*[:(=])')
+
+
+def string_constants(q, read=None):
+ """({'TOPICS.CREATED': 'doc.created', 'CREATED_TYPE': 'doc.created', …}, {(file, line)}): the string each constant
+ name denotes, where every declaration of the name agrees, and the lines that declare them (a literal there is the
+ constant's definition, not a write of its value)."""
+ if not _has(q, 'symbols'):
+ return {}, set()
+ read = read or _source_reader(q)
+ seen = collections.defaultdict(set)
+ pos = set()
+ types = {i: n for i, n in q("SELECT id, name FROM symbols WHERE id IS NOT NULL AND method_id IS NULL AND type_id IS NOT NULL")}
+ for n, f, a, b, owner, kind in q("""SELECT name, file, line, end_line, owner, kind FROM symbols
+ WHERE method_id IS NULL AND name IS NOT NULL AND file IS NOT NULL AND line > 0
+ AND kind NOT IN ('class', 'interface', 'enum', 'record', 'struct', 'module', 'type', 'namespace')"""):
+ L = read(f)
+ if not L or not re.fullmatch(_IDENT, n): continue
+ b = max(a, min(b or a, a + 400, len(L)))
+ oname = (types.get(owner) or (owner or '').split('.')[-1]) if owner else ''
+ m = _CONST_DECL.search(L[a - 1]) if a <= len(L) else None
+ if m and m.group(1) == n and b == a:
+ seen[f'{oname}.{n}' if oname else n].add(m.group(3)); pos.add((f, a))
+ continue
+ for ln in range(a, b + 1):
+ for k, _qt, v in _CONST_ENTRY.findall(L[ln - 1]):
+ seen[f'{n}.{k}'].add(v); pos.add((f, ln))
+ return {k: next(iter(vs)) for k, vs in seen.items() if len(vs) == 1}, pos
+
+
+def _entry_key(m, consts):
+ """the string a matched entry is keyed by: its literal, or the constant it names resolved; None when unknown"""
+ ref, lit, cref = m.group(1), m.group(3), m.group(4)
+ if lit: return lit
+ return consts.get(ref or cref)
+
+
+def table_registrations(q, site_file=None, consts=None):
+ """[(decl, file, line, 'table', key, why)] — a callable registered in a handler table under the entry's key.
+ An entry in a test file is a fixture's table, and is not what the application dispatches on."""
+ if not _has(q, 'symbols'):
+ return []
+ sf = site_file or (lambda x: x)
+ read = _source_reader(q)
+ consts = string_constants(q, read)[0] if consts is None else consts
+ why = lambda key: f'registered in a handler table under "{key}" here — whoever dispatches the table by that key calls it, no call site does'
+ out = set()
+ by_line = collections.defaultdict(list)
+ for i, f, l in q("""SELECT id, file, line FROM symbols WHERE method_id IS NOT NULL AND file IS NOT NULL AND line > 0
+ AND (is_test IS NULL OR is_test = 0) AND kind NOT IN ('module', 'constructor')"""):
+ by_line[(f, l)].append(i)
+ for (f, l), ids in by_line.items():
+ L = read(f)
+ if not L or l > len(L) or len(ids) != 1: continue
+ m = _ENTRY_DECL.match(L[l - 1])
+ key = _entry_key(m, consts) if m else None
+ if key: out.add((ids[0], sf(f), l, 'table', key, why(key)))
+ # an entry whose value NAMES the handler: the declaration a name identifies uniquely, as `registrations()` requires
+ if _has(q, 'refs'):
+ once = {}
+ for n, i, c in q("""SELECT name, min(id), count(*) FROM symbols WHERE method_id IS NOT NULL AND name IS NOT NULL
+ AND name NOT LIKE '<%' GROUP BY name"""):
+ if c == 1: once[n] = i
+ tf = {x for (x,) in q("SELECT DISTINCT file FROM symbols WHERE is_test = 1 AND file IS NOT NULL")}
+ for n, f, l in q("SELECT DISTINCT name, file, line FROM refs WHERE line > 0"):
+ if n not in once or f in tf: continue
+ L = read(f)
+ if not L or l > len(L): continue
+ m = _ENTRY_REF.match(L[l - 1])
+ if not m or m.group(5).split('.')[-1] != n: continue
+ key = _entry_key(m, consts)
+ if key: out.add((once[n], sf(f), l, 'table', key, why(key)))
+ return sorted(out)
+
+
+def table_key_writes(q, keys, consts=None, cpos=None):
+ """[(value, file, line)] — where a handler-table key in `keys` is WRITTEN: a literal of it, or a constant that
+ resolves to it, outside a key position and outside the constant's own declaration. What a table's key is joined to."""
+ if not keys:
+ return []
+ read = _source_reader(q)
+ if consts is None or cpos is None:
+ consts, cpos = string_constants(q, read)
+ out = set()
+ def written(text, token):
+ for mm in re.finditer(re.escape(token), text):
+ # a constant is not the tail of a longer name (`MY_TOPICS.X`) or the head of a longer chain (`TOPICS.X.y`)
+ if token[0] not in '\'"`' and (re.match(r'[\w$]', text[mm.start() - 1:mm.start()] or ' ')
+ or re.match(r'[\w$.]', text[mm.end():mm.end() + 1] or ' ')): continue
+ if not _KEY_POS.match(text, mm.end()): return True
+ return False
+ if _has(q, 'literals'):
+ for v, f, l in q("SELECT value, file, line FROM literals WHERE line > 0 AND value IS NOT NULL"):
+ if v not in keys or (f, l) in cpos: continue
+ L = read(f)
+ text = L[l - 1] if L and l <= len(L) else None
+ if text is None or written(text, f"'{v}'") or written(text, f'"{v}"') or written(text, f'`{v}`'):
+ out.add((v, f, l))
+ names = collections.defaultdict(set) # last segment -> the constants it may end
+ for c, v in consts.items():
+ if v in keys: names[c.split('.')[-1]].add(c)
+ if names and _has(q, 'refs'):
+ for n, f, l in q("SELECT DISTINCT name, file, line FROM refs WHERE line > 0"):
+ if n not in names or (f, l) in cpos: continue
+ L = read(f)
+ if not L or l > len(L): continue
+ for c in names[n]:
+ if written(L[l - 1], c): out.add((consts[c], f, l))
+ return sorted(out)
# ── the same join the rules make, for a caller that has no Datalog ───────────────────────────────────────────
@@ -1012,16 +1155,22 @@ def key_edges(q, at, site_file=None, cap=None, use_cap=None):
if not _has(q, 'literals'):
return []
reg = collections.defaultdict(set)
- for decl, _f, _l, _kind, key, _why in all_registrations(q, site_file):
- if decl and key: reg[key].add(decl)
+ table = collections.defaultdict(set) # a handler table's key -> the declarations it registers
+ for decl, _f, _l, kind, key, _why in all_registrations(q, site_file):
+ if decl and key:
+ reg[key].add(decl)
+ if kind == 'table': table[key].add(decl)
if not reg:
return []
writes = collections.defaultdict(set)
- for v, f, l in q("SELECT value, file, line FROM literals WHERE line > 0 AND value IS NOT NULL"):
+ for v, f, l in key_writes(q, set(table)):
if not isinstance(v, str) or len(v) > 160: continue
c = at(f, l)
- if c: writes[v].add(c)
- capped = {k for k, ds in reg.items() if len(ds) > cap} | {k for k, cs in writes.items() if len(cs) > use_cap}
+ if c and c not in table.get(v, ()): writes[v].add(c)
+ # the rules' table_key: a table key's writers in test files drive its handler and are not counted against it
+ tests = {i for (i,) in q("SELECT id FROM symbols WHERE is_test = 1 AND method_id IS NOT NULL")} if table else set()
+ capped = {k for k, ds in reg.items() if len(ds) > cap} | {k for k, cs in writes.items()
+ if len(cs - tests if k in table else cs) > use_cap}
out = set()
for v, callers in writes.items():
for key in route_candidates(v, reg):
@@ -1030,3 +1179,14 @@ def key_edges(q, at, site_file=None, cap=None, use_cap=None):
for d in reg[key]:
if c != d: out.add((c, d, key))
return sorted(out)
+
+
+def key_writes(q, table_keys=None):
+ """[(value, file, line)] — every string a callable writes that a registration key may be joined to: the literals,
+ except that a handler table's key is written where table_key_writes says (a dotted literal or a constant, never
+ the table's own key position or the constant's declaration)."""
+ if table_keys is None:
+ table_keys = {r[4] for r in table_registrations(q)}
+ rows = [(v, f, l) for v, f, l in q("SELECT value, file, line FROM literals WHERE line > 0 AND value IS NOT NULL")
+ if v not in table_keys] if _has(q, 'literals') else []
+ return rows + table_key_writes(q, table_keys)
diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode
index c503f33d..cfcaf9ff 100755
--- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode
+++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode
@@ -1,5 +1,27 @@
#!/bin/bash
-# axiomcode — the one entry point. Every capability is a subcommand here; a new skill is a new subcommand, never a new tool.
+# 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.
+# axiomcode path
+# how A reaches B: every hop of the call chain, with the code at each call.
+# axiomcode tests
+# the tests your uncommitted edits reach, and the command that runs exactly those.
+# axiomcode index [] [--lang [,…]] [--src ] [--library [,…]]
+# build the graph (the first query builds it too). defaults to the current directory.
+#
+# Names are written as in the code: Owner.method, function, Type, or file.py:123. `axiomcode help ` for one verb.
+
+# THE HELP ABOVE IS THE PUBLIC SURFACE: helptext() prints the comment block up to the blank line above. The verbs below
+# are still dispatched and keep every flag -- the hooks, the test suites and scripts call them -- but they are internal
+# and are not advertised on any user- or agent-facing surface. find is context and tests is test-impact underneath;
+# at the front door (bin/axiomcode sets AXIOMCODE_FRONT, the MCP server AXIOMCODE_SURFACE=mcp) a query with no flag
+# answers as places with their code (ax_blocks.py), and any flag, AXIOMCODE_RAW=1 or a direct call gives the verb's
+# own answer.
#
# axiomcode index [] [--lang java|typescript|python|javascript|csharp] [--src ] [--library [,…]]
# the pipeline: parser → engine → .axiomcode/out/graph.sqlite (+ index). defaults to the current directory.
@@ -110,7 +132,12 @@ helptext(){ awk 'NR>1 && /^#/ {sub(/^# ?/, ""); print; next} NR>1 {exit}' "$0";
# one verb's own usage, from the script that implements it: a python docstring, or a bash file's
# leading comment block. The verb documents itself once, where it is implemented.
verbhelp(){
- local f="$H/axiomcode-$1"; [ "$1" = index ] && f="$H/axiomcode-build"; [ "$1" = tests ] && f="$H/axiomcode-test-impact"
+ # 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)
+ helptext | awk -v v="$1" '$0 ~ "^ axiomcode "v"( |$)" {on=1; print; next} on && /^ axiomcode / {exit} on && /^$/ {exit} on {print}'
+ return 0 ;;
+ esac
+ local f="$H/axiomcode-$1"; [ "$1" = index ] && f="$H/axiomcode-build"; [ "$1" = tests ] && f="$H/axiomcode-test-impact"; [ "$1" = find ] && f="$H/axiomcode-context"
[ -f "$f" ] || { echo "axiomcode: no such verb '$1' — try: $(verbs | tr '\n' ' ')" >&2; return 2; }
if head -1 "$f" | grep -q python; then python3 -c 'import ast,sys; print(ast.get_docstring(ast.parse(open(sys.argv[1]).read())) or "")' "$f"
else awk 'NR>1 && /^#/ {sub(/^# ?/, ""); print; next} NR>1 {exit}' "$f"; fi
@@ -176,7 +203,7 @@ LASTPOS=""; [ "${#POS[@]}" -gt 0 ] && LASTPOS="${POS[${#POS[@]}-1]}"
case "$cmd" in
index|build) if [ "${#POS[@]}" -gt 0 ] && [ ! -d "${POS[0]}" ]; then gone "${POS[0]}"; fi ;;
graph) case "${POS[0]:-}" in ""|build|export|draw) ;; *) [ -d "${POS[0]}" ] || gone "${POS[0]}" ;; esac ;;
- context) if [ "${#POS[@]}" -gt 1 ] && [ ! -d "${POS[1]}" ]; then gone "${POS[1]}"; fi ;;
+ context|find) if [ "${#POS[@]}" -gt 1 ] && [ ! -d "${POS[1]}" ]; then gone "${POS[1]}"; fi ;;
path) if [ "${#POS[@]}" -gt 2 ] && [ ! -d "${POS[2]}" ]; then gone "${POS[2]}"; fi ;;
impact) if [ "${#POS[@]}" -gt 1 ] && [ ! -e "$LASTPOS" ] && dirlike "$LASTPOS"; then gone "$LASTPOS"; fi ;;
changed|test-impact|tests) if [ "${#POS[@]}" -gt 0 ] && [ ! -e "${POS[0]}" ] && dirlike "${POS[0]}"; then gone "${POS[0]}"; fi ;;
@@ -191,6 +218,25 @@ 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 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,
+# 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.
+B=""; FRONT="${AXIOMCODE_FRONT:-}"; [ "${AXIOMCODE_SURFACE:-}" = mcp ] && FRONT=1; [ -n "${AXIOMCODE_RAW:-}${GREP:-}" ] && FRONT=""
+if [ -n "$FRONT" ]; then for a in ${ARGS[@]+"${ARGS[@]}"}; do case "$a" in -*) FRONT="" ;; esac; done; fi
+case "$cmd" in
+ find) cmd=context; [ -n "$FRONT" ] && B=find ;;
+ path) [ -n "$FRONT" ] && B=path ;;
+ tests|test-impact) [ -n "$FRONT" ] && B=tests ;;
+ impact) if [ -n "$FRONT" ]; then
+ named=""; for a in ${ARGS[@]+"${ARGS[@]}"}; do [ -d "$a" ] || named=1; done
+ [ -z "$named" ] && exec python3 "$H/ax_blocks.py" edits "$FR"
+ ARGS+=(--tests); B=impact
+ fi ;;
+esac
+[ -n "$B" ] && G=(python3 "$H/ax_blocks.py" "$B" "$FR" --)
# A REPOSITORY IN SEVERAL LANGUAGES has one graph per language (.axiomcode/lang/ beside the main one): a query
# asks every one of them (ax_langs.py), so no language's code is left out of an answer. A graph named in
# AXIOMCODE_GRAPH was chosen by the caller and is asked alone.
@@ -204,7 +250,7 @@ if [ -f "$FR/.axiomcode/out/graph.sqlite" ] || [ -L "$FR/.axiomcode/out/graph.sq
fi
case "$cmd" in
index|build) exec bash "$H/axiomcode-build" ${ARGS[@]+"${ARGS[@]}"} ;;
- context) exec ${G[@]+"${G[@]}"} python3 ${Q:+"$Q" "$FR"} "$H/axiomcode-context" ${ARGS[@]+"${ARGS[@]}"} ;;
+ context|find) exec ${G[@]+"${G[@]}"} python3 ${Q:+"$Q" "$FR"} "$H/axiomcode-context" ${ARGS[@]+"${ARGS[@]}"} ;;
path) exec ${G[@]+"${G[@]}"} python3 ${Q:+"$Q" "$FR"} "$H/axiomcode-path" ${ARGS[@]+"${ARGS[@]}"} ;;
impact) exec ${G[@]+"${G[@]}"} python3 ${Q:+"$Q" "$FR"} "$H/axiomcode-impact" ${ARGS[@]+"${ARGS[@]}"} ;;
changed) exec python3 ${Q:+"$Q" "$FR"} "$H/axiomcode-changed" ${ARGS[@]+"${ARGS[@]}"} ;;
diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact
index a2b15125..4e300359 100755
--- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact
+++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact
@@ -153,8 +153,10 @@ def ckey(k):
# framework's own registration, so it outranks every name match, and it is not a call to the declaration.
# `stubs it` (a call inside a mock's stub or verification) is the engine's resolution, so it outranks every name match,
# and runs nothing, so it sits below every hop that does: a rename breaks it, a body change never does.
+# `by key` (a handler table's entry under the type a publisher writes, dl/impact.dl) is joined on a string both ends
+# spell and nothing the engine resolved: the weakest hop after a name match, as it is in the closure.
CERT = {'resolved': 0, 'one of a set': 1, 'registered': 2, 'capped set': 3, 'remote': 4, 'framework': 5,
- 'stubs it': 6, 'in scope': 7, 'by name': 8, 'text': 9, 'alongside': 10}
+ 'stubs it': 6, 'in scope': 7, 'by name': 8, 'by key': 8.5, 'text': 9, 'alongside': 10}
def also_text(whys, shown=2):
"""the other reasons a row's callable has, said on the same line: `; also: ` (one row per dependent)"""
if not whys: return ''
@@ -1099,7 +1101,7 @@ class Impact:
W('cs_fixture_type', sorted(x for x in fixt if not x[0].startswith('collection:')))
# ── facts: the graph, exported once (reused while graph.sqlite is unchanged) ────────────────────────────────
- IMPACT_VERSION = '55' # 55: cs_data_source, cs_data_type, cs_fixture_type, the C# test links a runner makes from a data attribute or a class/collection fixture (#1498, #1499); 53: implicit_new, the type a C# `new T()` constructs where T writes no constructor (#1473); 52: test_method holds a method under a composed or derived test marker declared in the repository (a Java annotation meta-annotated @Test, a C# attribute derived from FactAttribute: #1418, #1497; 51 was the C# test-links branch's number, landed as 55); 48: sigtype, a parameter / return position type_use resolves to a type, read before the textuse grep (#1422), and persist_field, the properties a persistence query reads (#1461); 47: test_method / fixture from graph_sql's one classification (a tear-down such as @AfterEach or [TestCleanup] is a fixture, [TestInitialize] is no test, an @Override is no named test: #1417 #1419 #1502), and reg_key_fact drops a string a decoration does not register under (#1413); 46: accessor carries the wither and an isX boolean's setX / withX, and a generated builder or fluent setter the engine resolved is a writer (#1404, #1409); 45: runs_before, a C# set-up an NUnit [SetUpFixture] or an MSTest assembly initializer runs for tests outside its type (#1501), stub rows for a member a Moq Protected() setup names by string (#1540), cs_config_literal for a Section:Key configuration key (#1443), and lex_parent puts a lambda under the declaration on its own line (#1556); 44: a C# MEMBER_ACCESS ref is qualified, so its qualifier decides (#1445); 41: spawns_fact, a test that runs a script by its path (ax_spawn.py); 40: test_method holds a script test's module (a test-tree file run as a program, no framework: graph_sql.script_tests); 39: a chained route link's `calls` row and `registration` label sit on the link's own line, with its own verb and path; 38: reg_key_fact drops a decoration string with a space in it (a description, not a key); 37: via_base / via_site, a caller that reaches a declaration through a base it is override-equivalent to (#1542), and injected_bean, the bean an injection point was wired to (#1384); 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key
+ IMPACT_VERSION = '57' # 57: a TypeScript object literal key is a ref of entity kind OBJECT_PROPERTY_KEY, kept past a bound access on its line; 56: reg_key_fact carries a handler table's entries (kind table), literal a table key written as a dotted string or through a constant, and test_code; 55: cs_data_source, cs_data_type, cs_fixture_type, the C# test links a runner makes from a data attribute or a class/collection fixture (#1498, #1499); 53: implicit_new, the type a C# `new T()` constructs where T writes no constructor (#1473); 52: test_method holds a method under a composed or derived test marker declared in the repository (a Java annotation meta-annotated @Test, a C# attribute derived from FactAttribute: #1418, #1497; 51 was the C# test-links branch's number, landed as 55); 48: sigtype, a parameter / return position type_use resolves to a type, read before the textuse grep (#1422), and persist_field, the properties a persistence query reads (#1461); 47: test_method / fixture from graph_sql's one classification (a tear-down such as @AfterEach or [TestCleanup] is a fixture, [TestInitialize] is no test, an @Override is no named test: #1417 #1419 #1502), and reg_key_fact drops a string a decoration does not register under (#1413); 46: accessor carries the wither and an isX boolean's setX / withX, and a generated builder or fluent setter the engine resolved is a writer (#1404, #1409); 45: runs_before, a C# set-up an NUnit [SetUpFixture] or an MSTest assembly initializer runs for tests outside its type (#1501), stub rows for a member a Moq Protected() setup names by string (#1540), cs_config_literal for a Section:Key configuration key (#1443), and lex_parent puts a lambda under the declaration on its own line (#1556); 44: a C# MEMBER_ACCESS ref is qualified, so its qualifier decides (#1445); 41: spawns_fact, a test that runs a script by its path (ax_spawn.py); 40: test_method holds a script test's module (a test-tree file run as a program, no framework: graph_sql.script_tests); 39: a chained route link's `calls` row and `registration` label sit on the link's own line, with its own verb and path; 38: reg_key_fact drops a decoration string with a space in it (a description, not a key); 37: via_base / via_site, a caller that reaches a declaration through a base it is override-equivalent to (#1542), and injected_bean, the bean an injection point was wired to (#1384); 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key
# layer; 15: the registration facts (two 14s landed independently, which is exactly the collision this
# guards); 16: regsite folded into ax_registration's reg_key_fact; 20: implements_pair (#1011); 17/18: the tagged-template test registrar
# (it.each`…`) and its table span
@@ -1385,6 +1387,16 @@ class Impact:
if ROUTEISH.fullmatch(r['value']):
c = self.at(r['file'], r['line'])
if c: lits.append((c, r['value'], r['file'], r['line']))
+ # A HANDLER TABLE'S KEY is written as whatever string the dispatcher reads: usually dotted (`doc.created`), and
+ # usually through a constant (`TOPICS.CREATED`), so neither shape above carries it. Its writes come from
+ # ax_registration, which also leaves out the table's own key positions and the constant's declaration.
+ tregs = ax_registration.table_registrations(g.q, g.site_file)
+ tkeys = {r[4] for r in tregs}
+ if tkeys:
+ lits = [x for x in lits if x[1] not in tkeys]
+ for v, f, l in ax_registration.table_key_writes(g.q, tkeys):
+ c = self.at(f, l)
+ if c: lits.append((c, v, f, l))
W('literal', sorted(set(lits)))
# a string written inside a decoration (@Listener(topics = "topicOne"), @RequestMapping("/a/{b}")) is in no
# other table: literals does not carry it, and it is how a topic, a queue, a route or a bean qualifier binds
@@ -1408,9 +1420,12 @@ 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)):
+ + ax_registration.registrations(g.q, g.site_file) + 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
+ # counted against the key the way a production writer is (dl/impact.dl, table_key)
+ W('test_code', sorted((i,) for i, s in g.sym.items() if s['is_test'] and s.get('method_id')))
# the HTTP method each side names, where it names one (ax_registration.route_verbs / literal_verbs)
W('reg_verb', sorted(x for x in ax_registration.route_verbs(g.q, g.site_file) if x[0] in g.sym))
W('lit_verb', sorted(ax_registration.literal_verbs(g.q, self.at, g.site_file)))
diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index
index 2bbd460a..830ba9a5 100755
--- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index
+++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index
@@ -94,6 +94,9 @@ A = {
# a property's name (`this.pending`, `row.setX`) is in literalValue with an empty potentialQualifiedName — 36k of 60k references in the parser repo
expr=dict(file='all-typescript-expressions.csv', kind='kind', name='potentialQualifiedName', nameFallback='literalValue', line='startLine', fileVia=('modules', 'tsModuleLinkHash'),
refKinds={'IDENTIFIER_REFERENCE', 'PROPERTY_ACCESS'}, entityKind='referencedEntityKind',
+ # the key of an object literal (`{ eventId: … }`) is stored with the entity kind OBJECT_PROPERTY_KEY, not
+ # UNKNOWN: it is never a property ACCESS, so an access the engine bound on the same line is not this name
+ keyRole=dict(role='edgeRole', value='OBJECT_PROPERTY_KEY'),
litKinds={'LITERAL'}, litType=('literalType', 'STRING'), litValue='literalValue'),
comments=dict(file='all-typescript-comments.csv', text='commentText', kind='commentKind', line='startLine', filePath='filePath'),
typeRefs=dict(file='all-typescript-type-references.csv', name='typeName', context='context', ownerKind='referenceOwnerKind', line='startLine', fileVia=('modules', 'tsModuleLinkHash')),
@@ -573,6 +576,7 @@ c.executemany("INSERT INTO symbols VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", sym)
e = A['expr']; refs = []; lits = []
namecol = e['name'] if isinstance(e['name'], str) else dict(x.split(':') for x in e['name'])
mname = e.get('memberName'); child_ek = {}
+kr = e.get('keyRole')
if mname: # the member-name child's entity kind, for the access that stands for it (the parent's own is UNKNOWN until resolved)
for r in rows(e['file']):
if r.get(mname['role']) == mname['value'] and r.get(mname['parent']): child_ek[r[mname['parent']]] = r.get(e['entityKind'], '')
@@ -591,6 +595,7 @@ for r in rows(e['file']):
n = n.rsplit('.', 1)[-1]
ek = r.get(e['entityKind'], '')
if mname and ek in ('', 'UNKNOWN'): ek = child_ek.get(r.get(mname['id'], ''), ek) or ek
+ if kr and ek in ('', 'UNKNOWN') and r.get(kr['role']) == kr['value']: ek = kr['value']
refs.append((n, file_of(r, e), int(r.get(e['line']) or 0), k, ek))
elif k in e['litKinds'] and r.get(e['litType'][0]) == e['litType'][1]:
v = (r.get(e['litValue']) or '')
diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-install b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-install
index 111cfa64..c562c060 100755
--- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-install
+++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-install
@@ -35,23 +35,23 @@ END = ''
BLOCK = """
## Finding code in this repository
-This repository has a resolved call graph. Ask it FIRST, through the
-`mcp__plugin_axiomcode_axiomcode__axiomcode_*` tools; no skill needs loading:
+This repository has a resolved call graph. Ask it FIRST, through the axiomcode MCP tools
+(`mcp__plugin_axiomcode_axiomcode__*`); no skill needs loading:
- axiomcode_context task="" # where the work is, when you have no name yet
- axiomcode_impact targets=[""] # what a change reaches: contract, users, tests
- axiomcode_path from_="" to="" # how A reaches B
+ 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 context ""`,
-`axiomcode impact