Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
2f13a96
index: look up symbols by id and refs and literals by file and line t…
swapnilpaliwal-sd Oct 1, 2026
b43cebe
Merge pull request #1847 from AxiomCodeAI/fix/graph-lookup-indexes
swapnilpaliwal-sd Oct 1, 2026
3b2a584
build: export impact's facts in the background once the graph is quer…
swapnilpaliwal-sd Oct 1, 2026
8077b7e
Merge remote-tracking branch 'origin/0.1.9' into pr1848
swapnilpaliwal-sd Oct 2, 2026
1cb2b38
Merge pull request #1848 from AxiomCodeAI/fix/graph-lookup-indexes
swapnilpaliwal-sd Oct 2, 2026
f574692
tests: a failing case prints the tail of its output, where a tracebac…
swapnilpaliwal-sd Oct 2, 2026
d65fd56
context: export the graph's facts before reading its edges
swapnilpaliwal-sd Oct 2, 2026
147f4a8
Merge pull request #1849 from AxiomCodeAI/fix/context-crash-after-bg-…
swapnilpaliwal-sd Oct 2, 2026
82a0ab2
impact/path: one facts export per graph, however many queries arrive …
swapnilpaliwal-sd Oct 2, 2026
1205b15
impact: an undeclared name that arrives through an import hints at --…
swapnilpaliwal-sd Oct 3, 2026
e8fc153
Merge pull request #1850 from AxiomCodeAI/perf/single-flight-export
swapnilpaliwal-sd Oct 3, 2026
6dde0de
impact/path: the facts export stops paying per-row SQL — 171 s to 55 …
swapnilpaliwal-sd Oct 3, 2026
0d9913f
Merge pull request #1851 from AxiomCodeAI/perf/site-file-per-graph
swapnilpaliwal-sd Oct 3, 2026
8ba21f1
The MCP surface is impact, path and tests: search is the agent's own
swapnilpaliwal-sd Oct 3, 2026
67034be
Merge apps/integration-0.1.9: their find removal wins; find leaves th…
swapnilpaliwal-sd Oct 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<question>"` | where the code for a task lives, when you have it in words and not yet a name |
| `axiomcode impact <name>` | 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 <A> <B>` | how A reaches B: every hop of the call chain, with the code at each call |
Expand Down
2 changes: 1 addition & 1 deletion bin/axiomcode
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
8 changes: 1 addition & 7 deletions plugins/axiomcode/skills/axiomcode/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Search with grep as usual; the graph answers what grep cannot. Use the MCP tools

| the question | MCP tool | shell |
|---|---|---|
| where is the code for this task? | grep; or, shell only | `axiomcode find "<question>"` |
| where is the code for this task? | your own search (grep), then bring the name here | — |
| who calls X, what does changing it reach, which tests? | `impact(name)` | `axiomcode impact <name>` |
| what do my uncommitted edits reach? | `impact()` | `axiomcode impact` |
| how does A reach B? | `path(start, end)` | `axiomcode path <A> <B>` |
Expand Down Expand Up @@ -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. Shell only: `axiomcode find "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:
Expand Down
7 changes: 7 additions & 0 deletions plugins/axiomcode/skills/axiomcode/scripts/ax_text.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <its source dir>`")
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 <that declaration>` for what a change reaches")
Expand Down
8 changes: 3 additions & 5 deletions plugins/axiomcode/skills/axiomcode/scripts/axiomcode
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<what you need, in words>"
# where the code for a task lives: the functions involved, most relevant first.
# axiomcode impact [<name>]
# 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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <name> is impact with its tests,
# (ax_blocks.py), so a place needs no read to be understood. impact <name> 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.
Expand Down
10 changes: 7 additions & 3 deletions plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 14 additions & 4 deletions plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact
Original file line number Diff line number Diff line change
Expand Up @@ -1322,8 +1322,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 --
Expand Down Expand Up @@ -1558,7 +1567,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
Expand Down Expand Up @@ -1640,7 +1650,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
Expand Down
6 changes: 6 additions & 0 deletions plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index
Original file line number Diff line number Diff line change
Expand Up @@ -706,6 +706,12 @@ 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);
-- 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,11 @@ Ask it directly, through the axiomcode MCP tools (`mcp__plugin_axiomcode_axiomco
path(start="<A>", end="<B>") # 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 "<task>"`,
Only when those tools are not in your list, the same from the shell:
`axiomcode impact <Owner.method>`, `axiomcode path <A> <B>`, `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*.
Expand Down
64 changes: 59 additions & 5 deletions plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path
Original file line number Diff line number Diff line change
Expand Up @@ -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 '.')
Expand Down Expand Up @@ -932,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 ───────────────────────────────
Expand All @@ -948,8 +991,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.
Expand Down Expand Up @@ -999,6 +1050,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
Expand Down
Loading
Loading