Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
21 changes: 20 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
133 changes: 70 additions & 63 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ That matters because an agent follows edges several hops deep, and one missed li
<img src="docs/images/impact-graph.png" width="640" alt="axiomcode graph of an open-source TypeScript web framework, 366 files and 8,657 call edges. Source files form the inner ring, test files the outer ring. A change to basicAuth reaches 7 test files through resolved calls (solid blue); the other 130 test files have no chain to it (dashed red). basicAuth calls a shared compare function (green) that 11 other files also reach (gold).">
</p>

*`axiomcode graph` on an open-source TypeScript web framework, asked which tests a change to `basicAuth` can
*The call graph of an open-source TypeScript web framework, drawn as a page and asked which tests a change to `basicAuth` can
affect. Source files form the inner ring and test files the outer one. The solid blue paths are chains of resolved
calls from `basicAuth` to the 7 test files that must run; the dashed red ones mark the other 130, which have no
chain to it and can be skipped. Green is the shared `compare` that `basicAuth` calls, and gold the 11 other files
Expand All @@ -123,7 +123,7 @@ established relationships that could lead to false positives.

AxiomCode Graph is two parts. The **engine** (`@axiomcode/code-graph` on npm) parses a repository and builds its
graph; it also provides the `axiomcode` command and an MCP server. The **plugin** (`plugins/axiomcode/`) is the
agent-facing frontend: a skill, seven MCP tools, and hooks. Install the engine first.
agent-facing frontend: a skill, four MCP tools, and hooks. Install the engine first.

Requirements: **Node ≥ 22.5** and **Python 3** (`python3`, or `python` / `py` on Windows). On Windows, also
[Git for Windows](https://git-scm.com/download/win): the CLI runs under its bash. The engine ships as a prebuilt
Expand Down Expand Up @@ -200,55 +200,62 @@ axiomcode path main Ledger.put
axiomcode path main SqlStore.put
```

```
main → Ledger.put: 1 of 1 target(s) reached through resolved calls; nearest at 2 hop(s)
2 call(s):
main src/main.ts:6
→ [known_edge · call @ src/main.ts:9] OrderService.place src/orders/orderService.ts:7
→ [known_edge · call @ src/orders/orderService.ts:8] Ledger.put src/ledger/ledger.ts:4
verified: every printed hop is an edge in the graph and a second, independent traversal finds the same length

main → SqlStore.put: 1 of 1 target(s) reached through resolved calls; nearest at 2 hop(s)
2 call(s):
main src/main.ts:6
→ [known_edge · call @ src/main.ts:9] OrderService.place src/orders/orderService.ts:7
→ [multi_inferred · call @ src/orders/orderService.ts:9] SqlStore.put src/storage/sqlStore.ts:6
verified: every printed hop is an edge in the graph and a second, independent traversal finds the same length
what the hops are:
[known_edge] resolved to one declaration
[multi_inferred] several declarations fit; each is a real candidate
```

The first chain is `known_edge` all the way: each call has exactly one target. The second ends in
`multi_inferred`, because `store.put` can run `SqlStore.put` or `MemoryStore.put`, depending on which store `main`
built; the graph keeps both as candidates instead of picking one.
Every answer is a numbered list of places, each with the code of the function it sits in and the line that matters
marked `→`:

````
1. src/main.ts:9 [resolved · hop 1/2 main → OrderService.place]
```typescript
6 export function main(useSql: boolean): void {
7 const store = useSql ? new SqlStore("orders") : new MemoryStore();
8 const service = new OrderService(new Ledger(), store);
→ 9 service.place("A-1", 42);
10 }
```
2. src/orders/orderService.ts:8 [resolved · hop 2/2 OrderService.place → Ledger.put]
```typescript
7 place(id: string, amount: number): void {
→ 8 this.ledger.put(`order ${id}: ${amount}`);
9 this.store.put(id, amount);
10 }
```
verified: ✓ (2 edge(s) looked up again)
````

The second chain ends the same way, at `src/orders/orderService.ts:9`, tagged `one of a set`: `store.put` can run
`SqlStore.put` or `MemoryStore.put`, depending on which store `main` built, and the graph keeps both as candidates
instead of picking one.

From an agent, ask in plain words. The skill tells the agent to query the graph instead of grepping:

```
````
> What breaks if I change SqlStore.put?

axiomcode_impact("SqlStore.put")
must change with it (1: bound by a contract the engine resolved):
Store.put src/storage/store.ts:2 — it implements this
reads or uses it (3 callable(s): 1 one of a set, 2 alongside):
[one of a set] OrderService.place src/orders/orderService.ts:9 — calls it
...
reaches those through resolved calls: 4 more callable(s) in 3 file(s)
src/main.ts: main → OrderService.place
tests: 1 of 1 test method(s) reach the change
test files: test/orderService.test.ts (1)
verified: 2 printed edge(s) looked up again in the graph, all present
```

The change reaches the entry point and the test through a call that never names `SqlStore`.

Each hop carries the line the call is on, how certain the edge is, and what kind of call it is. Every printed
edge is looked up again in the graph before you see it; the `verified:` line is that check reporting.
impact("SqlStore.put")
1. src/storage/store.ts:2 [must change · it implements this]
```typescript
→ 2 put(key: string, value: number): void;
```
2. src/orders/orderService.ts:9 [one of a set · OrderService.place]
```typescript
7 place(id: string, amount: number): void {
8 this.ledger.put(`order ${id}: ${amount}`);
→ 9 this.store.put(id, amount);
10 }
```
3. src/main.ts:6 [hop 2]
...
4. test/orderService.test.ts:3 [test · one of a set · hop 3]
...
verified: ✓ (3 edge(s) looked up again)
````

The change reaches the entry point and the test through a call that never names `SqlStore`. Every printed edge is
looked up again in the graph before you see it; the `verified:` line is that check reporting.

### Support for agents

Every agent below gets the seven MCP tools and the skill; the hooks, which add the graph's edges to the agent's
Every agent below gets the four MCP tools and the skill; the hooks, which add the graph's edges to the agent's
own file reads and searches, run where the last column says so.

| Agent | Install | Uninstall | Hooks |
Expand Down Expand Up @@ -301,27 +308,28 @@ about one change had to read 95 of 43,793 methods, and every true direct caller

## CLI commands

| command | what it does |
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()`.

| command | what it answers |
|---|---|
| `axiomcode path <A> <B>` | the chain of calls from A to B, hop by hop. `'*'` as one end gives the whole closure |
| `axiomcode impact <target>` | everything that has to be looked at again when a declaration changes, each labelled with how certain it is. `--tests` adds the tests that reach it |
| `axiomcode test-impact` | which tests have to run for the current edit, with the chain that reaches each |
| `axiomcode changed` | which declarations an edit changed, and how (signature, type, body, added, removed). `--impact` adds what that reaches |
| `axiomcode context "<task>"` | where a task's words land in the code, when you have a problem statement and not yet a name |
| `axiomcode graph` | the whole graph as one self-contained HTML page, at `.axiomcode/graph/graph.html`, drawn from the existing graph (rebuilt first only when stale, with the flags it was indexed with) |
| `axiomcode index` | build or rebuild the graph explicitly; `--lang`, `--src` and `--library` narrow it |
| `axiomcode mcp` | serve the graph to an agent as MCP tools over stdio |

A target is written the way it appears in the code: `Owner.method`, `method`, `Type`, `Owner.field`, or
`file.py:123`. It is resolved exactly; a miss lists the nearest names. `--range <a>..<b>` compares two commits. The
query commands take `--json`. `axiomcode help <command>` prints one command's usage.
| `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 |
| `axiomcode tests` | the tests your uncommitted edits reach, and a last `run:` line with the command that runs them |
| `axiomcode index` | build the graph explicitly (the first query builds it too); `--lang`, `--src` and `--library` narrow it |

A name is written the way it appears in the code: `Owner.method`, `method`, `Type`, `Owner.field`, or
`file.py:123`. It is resolved exactly; a miss lists the nearest names. `axiomcode help <command>` prints one
command's usage.

> [!NOTE]
> `changed` and `test-impact` compare the working tree with a **baseline**: the last commit (right after an explicit
> `axiomcode index`, the tree it indexed). The background refresh (below) resets it whenever HEAD moves (a commit, a
> merge, a pull, a checkout), so committed edits drop out and nothing accumulates; the two commands wait up to 30 s
> for that. While edits are uncommitted, they read the baseline's own graph, kept in `.axiomcode/base`, so a removed
> method still shows all its callers.
> `impact` with no name and `tests` compare the working tree with a **baseline**: the last commit (right after an
> explicit `axiomcode index`, the tree it indexed). The background refresh (below) resets it whenever HEAD moves (a
> commit, a merge, a pull, a checkout), so committed edits drop out and nothing accumulates. While edits are
> uncommitted, they read the baseline's own graph, kept in `.axiomcode/base`, so a removed method still shows all its
> callers.

The graph stays current on its own. Every file the parser reads is recorded with its hash at build time; after an
edit, a shell command, a finished turn, at session start, and before a query, anything that differs starts one
Expand All @@ -334,8 +342,7 @@ a session sits idle. The graph records when and why it was built in `index_meta`
`AXIOMCODE_NO_REFRESH=1` turns the rebuilds off, not the check: an answer from a graph older than an edit still
ends with a `graph refresh: OFF` line naming the files it predates. When a name asked about finds nothing and an
edit since the graph was built writes that name, the line says so, since the declaration may simply be too new
for the graph. `--no-refresh` on any query verb (MCP `refresh=false`) does the same for one query: a read-only answer
from the graph as it is. A query that does start a rebuild says so on its answer's first line, with the reason. The
for the graph. A query that does start a rebuild says so on its answer's first line, with the reason. The
hooks and the MCP server's timer never rebuild a graph another axiomcode built (another engine, other rules or another
`IMPACT_VERSION` in its build stamp); a hook says so once per session. The log is `.axiomcode/refresh.log`.

Expand Down
35 changes: 17 additions & 18 deletions bin/axiomcode
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────────────────────
# axiomcode — build a call graph from a source tree.
# 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.
# ─────────────────────────────────────────────────────────────────────────────
# INTERNAL COMMANDS, not advertised: the build, the engine suites and the MCP server, which
# the dispatcher, the test suites and the agent manifests call.
#
# Usage:
# bin/axiomcode <src-dir> <out-dir> [--library <path>[,…]] [--exclude-tests] [--version V]
Expand Down Expand Up @@ -88,9 +92,8 @@ PARSER="${AXIOM_PARSER:-$ROOT/parser/dist/index.js}"
# (`--verbs`, derived from its own dispatch table) so the two entry points cannot drift: a new
# capability is a new row in one case statement, not a new copy here.
QUERY="$ROOT/plugins/axiomcode/skills/axiomcode/scripts/axiomcode"
# `tests` is the frontend's alias for test-impact; `test` is THIS command's engine suite. One letter
# apart, entirely different jobs, and putting both on one command is a trap — so the alias is not
# routed here, and the name is refused below with both readings spelled out.
# `tests` is a query verb (the tests an edit reaches); `test` is THIS command's engine suite. The query
# surface advertises `tests`; `test` is internal.
# The list is read from the frontend's own dispatch table, the lines ` <verb>[|<alias>]) exec …` that its `--verbs`
# prints, so the two entry points still cannot drift -- but read here, in this shell, instead of by a second bash, a
# sed, a tr and two greps on every query.
Expand All @@ -100,19 +103,18 @@ load_verbs(){
local l v x re='^ ([a-z|-]*)\) *exec '
while IFS= read -r l || [ -n "$l" ]; do
[[ $l =~ $re ]] || continue; v="${BASH_REMATCH[1]}"
for x in ${v//|/ }; do [ "$x" = tests ] || QVERBS+=("$x"); done
for x in ${v//|/ }; do QVERBS+=("$x"); done
done < "$QUERY"
}
query_verbs(){ load_verbs; [ ${#QVERBS[@]} -gt 0 ] && printf '%s\n' "${QVERBS[@]}"; return 0; }
is_query_verb(){ load_verbs; local x; for x in ${QVERBS[@]+"${QVERBS[@]}"}; do [ "$x" = "$1" ] && return 0; done; return 1; }
usage(){
awk 'NR > 2 && /^# ─/ {exit} NR > 2' "$0" | sed 's/^# \{0,1\}//'
if [ -f "$QUERY" ]; then
echo
echo 'ASKING THE GRAPH — `axiomcode help <verb>` for any one of them:'
bash "$QUERY" --help | sed -n 's/^ \(axiomcode [a-z].*\)$/ \1/p'
fi
# the query surface is the dispatcher's own help, so the two cannot drift; the build commands above are internal
if [ -f "$QUERY" ]; then bash "$QUERY" --help
else awk 'NR > 2 && /^# ─/ {exit} NR > 2' "$0" | sed 's/^# \{0,1\}//'; fi
}
# the verbs the help advertises (` axiomcode <verb>` lines), for the message a typo gets
public_verbs(){ [ -f "$QUERY" ] && bash "$QUERY" --help | sed -n 's/^ axiomcode \([a-z][a-z-]*\).*/\1/p'; return 0; }
die(){ echo "axiomcode: $*" >&2; exit 2; }
need_parser(){ [ -f "$PARSER" ] || { echo "axiomcode: parser not built at $PARSER — run: npm install && npm run build" >&2; exit 1; }; }

Expand All @@ -129,25 +131,22 @@ case "$cmd" in
if [ $# -gt 1 ] && is_query_verb "$2"; then exec bash "$QUERY" help "$2"; fi ;;
"") ;;
# THE MCP SERVER IS THE PLUGIN'S, NOT A SECOND ONE. The package already ships plugins/axiomcode/,
# so an agent that is not Claude Code gets the same seven tools from one line of MCP config and
# so an agent that is not Claude Code gets the same four tools from one line of MCP config and
# no plugin install. launch.js picks the interpreter and falls back to a built-in protocol
# implementation, so nothing beyond the python3 the query verbs already need has to be installed.
mcp) shift
export AXIOMCODE_PLUGIN_ROOT="$ROOT/plugins/axiomcode"
exec node "$ROOT/plugins/axiomcode/mcp/launch.js" "$@" ;;
tests) echo "axiomcode: 'tests' is ambiguous here — 'axiomcode test-impact' for the tests an edit needs," >&2
echo " 'axiomcode test' for this package's own engine suite." >&2; exit 2 ;;
*) if is_query_verb "$cmd"; then shift; exec bash "$QUERY" "$cmd" "$@"; fi ;;
# the installed command is a front door: a query asked here with no flags answers as places with their code
*) if is_query_verb "$cmd"; then shift; export AXIOMCODE_FRONT=1; exec bash "$QUERY" "$cmd" "$@"; fi ;;
esac
# A NAME THAT IS NOT A VERB AND NOT A DIRECTORY IS A TYPO, NOT A BUILD. `*) cmd=all` is convenient
# for `axiomcode ./src out/` and wrong for everything else: a misspelled verb used to be parsed as a
# source tree and fail with advice about building the parser.
case "$cmd" in
parser|engine|all|test|-h|--help|help|"") [ $# -gt 0 ] && shift;;
*) [ -d "$cmd" ] || { echo "axiomcode: '$cmd' is neither a verb nor a directory." >&2
echo " build: parser engine all test (or: axiomcode <src-dir> <out-dir>)" >&2
echo " serve: mcp" >&2
echo " ask: $(query_verbs | tr '\n' ' ')" >&2
echo " ask: $(public_verbs | tr '\n' ' ')" >&2
echo " \`axiomcode help\` for what each one does." >&2; exit 2; }
cmd=all;;
esac
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "name": "@ws/app", "type": "module" }
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { f, Bus } from '@ws/lib';
import { g } from '@ws/lib/sub';
import { deep } from 'ws-tools/deep';
// Control: a package this repository does not declare stays unresolved.
import { outside } from 'not-in-this-repo';

export function run() {
f();
g();
deep();
new Bus().publish('k');
return outside();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "@ws/lib",
"main": "./dist/index.js",
"exports": {
".": { "import": "./dist/index.mjs", "require": "./dist/index.js" },
"./sub": "./dist/sub/index.js"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export function f() {
return 1;
}

export class Bus {
publish(key) {
return key;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function g() {
return 2;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "name": "ws-tools", "main": "dist/index.js" }
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function deep() {
return 3;
}
Loading
Loading