Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
3d60d3e
impact: a test whose registrar call opens on an earlier line is a test
swapnilpaliwal-sd Oct 10, 2026
0ad6936
impact: a vitest / jest set-up file runs before the tests it configures
swapnilpaliwal-sd Oct 10, 2026
25296c2
typescript: Object.assign(fn, members) evaluates to fn, so a call of …
swapnilpaliwal-sd Oct 10, 2026
26b20b4
query: a call that selected an overload signature reaches the overloa…
swapnilpaliwal-sd Oct 10, 2026
c933dd3
fix(impact): two static over-reach sources in Python transitive impact
swapnilpaliwal-sd Oct 10, 2026
65510f0
fix(python-engine): a class-object parameter under a union constructs…
swapnilpaliwal-sd Oct 10, 2026
b9c2707
Merge branch 'apps/integration-0.1.9' into fix/context-sensitive-reach
swapnilpaliwal-sd Oct 10, 2026
f0edf90
feat(links): agent-asserted call edges — list unknown sites, link the…
swapnilpaliwal-sd Oct 10, 2026
5a39161
javascript: a test that never loads the change is not selected; a moc…
swapnilpaliwal-sd Oct 10, 2026
17d6faa
Merge origin/apps/integration-0.1.9 into fix/js-impact-f1
swapnilpaliwal-sd Oct 10, 2026
ea07409
feat(links): columns, rejections, candidates, derived edges on a link…
swapnilpaliwal-sd Oct 10, 2026
98a78e9
feat(links): a construction links only to its type; not:<target> reje…
swapnilpaliwal-sd Oct 10, 2026
ac21c00
docs(readme): the axiomcode link row in the CLI table
swapnilpaliwal-sd Oct 10, 2026
3407450
Merge remote-tracking branch 'origin/fix/context-sensitive-reach' int…
swapnilpaliwal-sd Oct 10, 2026
6aa8e76
Merge branch 'fix/js-impact-f1' into feat/asserted-edges
swapnilpaliwal-sd Oct 10, 2026
fd88fb6
Merge branch 'feat/asserted-edges' into fix/ts-recall-next
swapnilpaliwal-sd Oct 10, 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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,7 @@ left to your own search: bring the name you found to these commands.
| `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 link <file:line> <target>` | record where a call the graph could not resolve lands (kept in `axiomcode-links.tsv`); impact, path and tests then walk it, labelled `[asserted]`. Alone, lists the links and whether each was applied |
| `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
Expand Down
7 changes: 7 additions & 0 deletions graph/python/engine/resolution/generics.dl
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,13 @@ param_class_object_bound("client", ph, t) :-
type_ref_nesting("client", r, _, "1", child),
type_ref("client", "TYPE_VAR", _, vn, _, child), vn != "",
typevar_bound("client", vn, t).
// …and the same under a union (`cls: type[CmdType] | None = None`): see param_class_object_ref.
param_class_object_bound("client", ph, t) :-
param_class_object_ref("client", ph, s),
type_ref_owner("client", ph, "METHOD_PARAM", r), s != r,
type_ref_nesting("client", s, _, _, child),
type_ref("client", "TYPE_VAR", _, vn, _, child), vn != "",
typevar_bound("client", vn, t).
expr_type_class_object("client", e, t) :-
expr_names_param("client", e, ph),
param_class_object_bound("client", ph, t).
22 changes: 22 additions & 0 deletions graph/python/engine/resolution/value-flow.dl
Original file line number Diff line number Diff line change
Expand Up @@ -450,13 +450,35 @@ expr_type_class_object(p, e, t) :-
annotation_names_a_class("type").
annotation_names_a_class("Type").

// ── param_class_object_ref(Prov, ParamHash, SubscriptRef) ─────────────────────
// The `type[...]` subscript a parameter's annotation IS, or one operand of its union is.
// `cls: type[Command] | None = None` (and `Optional[type[Command]]`) is the default-None
// spelling of the same class-object parameter: the body replaces None with a default class
// and calls `cls(...)`. Read only at the top level, that call stayed unresolved, and so did
// every constructor reached through it.
param_class_object_ref("client", ph, r) :-
type_ref_owner("client", ph, "METHOD_PARAM", r),
type_ref("client", _, "METHOD_PARAM", tn, _, r),
annotation_names_a_class(tn).
param_class_object_ref("client", ph, s) :-
type_ref_owner("client", ph, "METHOD_PARAM", r),
union_operand("client", r, s),
type_ref("client", _, _, tn, _, s),
annotation_names_a_class(tn).

expr_type_class_object("client", e, t) :-
expr_names_param("client", e, ph),
type_ref_owner("client", ph, "METHOD_PARAM", r),
type_ref("client", _, "METHOD_PARAM", tn, _, r),
annotation_names_a_class(tn),
type_ref_nesting("client", r, _, "1", child),
type_ref_resolved("client", t, child).
expr_type_class_object("client", e, t) :-
expr_names_param("client", e, ph),
param_class_object_ref("client", ph, s),
type_ref_owner("client", ph, "METHOD_PARAM", r), s != r,
type_ref_nesting("client", s, _, _, child),
type_ref_resolved("client", t, child).

// A name written MORE THAN ONCE takes the union, for the same reason the instance side
// does: a sound set beats a blank, and the tier follows from the target count.
Expand Down
1 change: 1 addition & 0 deletions graph/python/souffle/decls_all.dl
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@
.decl binding_element_lib_type(c0:symbol,c1:symbol)
.decl param_declared_type_by_parser(c0:symbol,c1:symbol,c2:symbol)
.decl union_operand(c0:symbol,c1:symbol,c2:symbol)
.decl param_class_object_ref(c0:symbol,c1:symbol,c2:symbol)
.decl type_ref_element(c0:symbol,c1:symbol,c2:symbol)
.decl annotation_owner_module(c0:symbol,c1:symbol,c2:symbol,c3:symbol)
.decl annotation_container_kind(c0:symbol)
Expand Down
16 changes: 16 additions & 0 deletions graph/typescript/engine/resolution/value-flow.dl
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,22 @@ value_branch(root, x) :- value_branch(root, e),
expr_kind_is_transparent(k),
expr_child("client", e, r, _, x),
edge_role_is_operand(r).
// `Object.assign(target, …sources)` evaluates to its TARGET. A callable API is often a function object built this way
// (`export const widget: Factory = assign(createWidget, members)`, `Object.assign(task, { started, finished })`)
// and typed by a callable interface whose call signature has no body: the holder held the call's result, which the
// platform's merge never returned a function for, so a call of it ran nothing. The merge is read the same way when
// it is reached through an alias (`export const assign = Object.assign`), never by the name `assign` alone.
value_branch(root, x) :- value_branch(root, e),
object_assign_call(e),
expr_child("client", e, "ARGUMENT", "0", x).
object_assign_ref(x) :- property_access_recv(x, "assign", q),
expr_name("client", "Object", q),
expr_kind("client", "IDENTIFIER_REFERENCE", _, q).
object_assign_call(e) :- call_callee_expr(_, e, ne), object_assign_ref(ne).
object_assign_call(e) :- call_callee_expr(_, e, ne),
expr_holder(ne, h),
holder_value(h, v),
object_assign_ref(v).
binary_op_yields_operand("??", "LEFT_OPERAND").
binary_op_yields_operand("??", "RIGHT_OPERAND").
binary_op_yields_operand("||", "LEFT_OPERAND").
Expand Down
2 changes: 2 additions & 0 deletions graph/typescript/souffle/decls_all.dl
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,8 @@
.decl field_has_declared_type(c0:symbol)
.decl field_in_enclosing(c0:symbol,c1:symbol,c2:symbol)
.decl holder_value(c0:symbol,c1:symbol)
.decl object_assign_ref(c0:symbol)
.decl object_assign_call(c0:symbol)
// #1208: nested element access, static callable fields, expression callees, object literals typed by context
.decl ref_via_alias(c0:symbol,c1:symbol)
.decl ref_elem_ref(c0:symbol,c1:symbol)
Expand Down
2 changes: 2 additions & 0 deletions plugins/axiomcode/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ that never spell the name:
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
link(site, target) record where an unresolved call lands, when the code makes it certain;
impact, path and tests then walk it, labelled [asserted]
context(task) how something works, as a narrative: the call flow step by step;
context(task, source=True) carries each step's code

Expand Down
10 changes: 10 additions & 0 deletions plugins/axiomcode/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,16 @@ def path(start: str, end: str) -> str:
written on. start / end as written in the code (Owner.method, function, Type)."""
return plain(run(['path', start, end, os.getcwd()]))

@srv.tool()
def link(site: str = '', target: str = '') -> str:
"""Record where an unresolved call lands, when you have read the code and the target is CERTAIN: site is the call's
file:line as an answer's `unknown:` block lists it, target the declaration it reaches (Owner.method, function, or
its file:line). impact, path and tests then walk the edge, labelled [asserted]. With no arguments: every link and
whether the graph took it (a link whose line changed is dropped, never trusted). target "-" removes the site's links;
target "not:<declaration>" rejects a lead (a by-name or one-of-a-set guess) at that site, which is then not walked.
Never link a guess, and never link a candidate for its rank alone."""
return plain(run(['link'] + ([site] if site.strip() else []) + ([target] if site.strip() and target.strip() else []) + [os.getcwd()]))

@srv.tool()
def tests() -> str:
"""The tests your uncommitted edits reach, each with its code, and the command that runs exactly those."""
Expand Down
2 changes: 2 additions & 0 deletions plugins/axiomcode/rules/axiomcode.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ that never spell the name:
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
link(site, target) record where an unresolved call lands, when the code makes it certain;
impact, path and tests then walk it, labelled [asserted]
context(task) how something works, as a narrative: the call flow step by step;
context(task, source=True) carries each step's code

Expand Down
21 changes: 21 additions & 0 deletions plugins/axiomcode/skills/axiomcode/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Search with grep as usual; the graph answers what grep cannot. Use the MCP tools
| what do my uncommitted edits reach? | `impact()` | `axiomcode impact` |
| how does A reach B? | `path(start, end)` | `axiomcode path <A> <B>` |
| which tests do my edits need, and how do I run them? | `tests()` | `axiomcode tests` |
| an answer lists an unresolved call I can see the target of | `link(site, target)` | `axiomcode link <file:line> <target>` |
| how does this work, start to finish? | `context(task, source=True)` | `axiomcode context "<task>" --source` |

Names are written as in the code: `Owner.method`, `function`, `Type`, or `file.py:123` for the declaration at that
Expand Down Expand Up @@ -59,6 +60,26 @@ Example: `path(start="main", end="Ledger.put")`.
The tests your uncommitted edits reach, each with its code, and a last line `run: <command>` that runs exactly those.
Example: `tests()`. It is a lower bound: a test reached only through reflection or a service loader is not listed.

## link

Answers are in three parts. CONFIRMED places are backed by an edge: `resolved` by the engine, or `asserted` by a link —
act on them. LEADS are reached only through a guess (`by name`, `by key`, `one of a set`, `text`) — check each before
relying on it. TO RESOLVE lists the calls the answer stopped at: the site as `file:line:col`, the call as written, why
the engine could not follow it (a value from `getattr`, a handler table, reflection, a callback) and the graph's
candidate targets with their `file:line`.

When the task depends on one of those sites, read the call. Only if the code makes the target CERTAIN, record it:
`link(site="app/dispatch.py:6:12", target="on_save")`, or `axiomcode link app/dispatch.py:6:12 on_save` from the shell.
A candidate is a lead: confirm it by reading the call, never link one because it is ranked first. From then on impact,
path and tests walk that edge, labelled `[asserted]`, never `resolved`; when the target declares a return type, the
calls made on its result (chained, or on a variable assigned from it) resolve too. When a lead at a site is wrong,
reject it: `link(site, "not:<target>")` — it is no longer walked; only a guess can be rejected, never an edge the
engine resolved. The links are kept in `axiomcode-links.tsv` at the repository root, which is worth committing.
`link()` with no arguments lists them and whether the graph took each one; `axiomcode link <file:line:col> -` removes
one. A link is refused when the call written there names a different declaration, or the target is not one; when
the line it was made on is edited, it is dropped and listed as stale, and the site is to resolve again. Never link a
guess: an asserted edge is trusted by every answer after it.

## context

How something works, from a task in your own words: the files and callables the task touches and, for a
Expand Down
30 changes: 27 additions & 3 deletions plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import ax_grep

CAP = 10 # places shown; the rest are counted
LEAD_CERTS = {'by name', 'by key', 'decorator by name', 'one of a set', 'text', 'in scope', 'capped set', 'protocol', 'library callback'}
FAR = 8 # places more than one hop away, named without code
DIRECT_CODE = 3 # direct callers shown with code even when a word grep also finds them
PLAIN_WHY = ('calls it', 'reads it', 'writes it', 'writes/reads it', 'references it', 'instantiates it')
Expand Down Expand Up @@ -140,7 +141,18 @@ def greppable(p):
far = [p for p in places.values() if is_far(p) and not is_test(p)]
places = {k: p for k, p in places.items() if not is_far(p) and not is_test(p)}
out = []
for i, p in enumerate(list(places.values())[:CAP], 1):
# CONFIRMED FIRST, THEN LEADS: a place backed by an edge (resolved, or asserted by a link) before one reached only
# through a guess (by name, by key, one of a set, text). One place per function, so a function reached both ways is
# listed once, as confirmed; the guesses get a heading of their own only when both kinds are present.
def is_lead(p):
certs = [t.split(' · ')[0].strip() for t in p['tags'] if ' · ' in t]
return bool(certs) and all(c in LEAD_CERTS for c in certs)
ordered = [p for p in places.values() if not is_lead(p)] + [p for p in places.values() if is_lead(p)]
places = {id(p): p for p in ordered}
any_confirmed = any(not is_lead(p) for p in ordered)
for i, p in enumerate(ordered[:CAP], 1):
if is_lead(p) and any_confirmed and (i == 1 or not is_lead(ordered[i - 2])):
out.append("leads — reached only through a guess; check each before relying on it:")
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'])
Expand Down Expand Up @@ -172,7 +184,17 @@ def greppable(p):
# from these places needs it as much as the verified: line, so it is never tidied away here.
# run: stays LAST: the answer ends with the command to run, whatever else the foot carries.
kept = [x for x in foot if x.startswith(('verified', 'bound:'))][:3]
out += kept + [x for x in foot if x.startswith('run:')][:1]
out += kept + unknown(doc, repo) + [x for x in foot if x.startswith('run:')][:1]
return out


UNKNOWN_SHOWN = 5 # unresolved sites listed under an answer: the nearest; the verbs' --json carries up to 30
def unknown(doc, repo):
"""the answer's gaps as a short work list (ax_links.py): where it stops being complete, and how to close one"""
import ax_links
sites = doc.get('unknown_sites') or []
out = ax_links.unknown_lines(repo, sites, doc.get('unknown_total') or len(sites), shown=UNKNOWN_SHOWN) if sites else []
if doc.get('links_note'): out.append(doc['links_note'])
return out


Expand Down Expand Up @@ -258,7 +280,9 @@ def main(argv):
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 doc.get('refusal') or r.stdout.strip()); return r.returncode
prose = '\n'.join(doc.get('prose') or []) or doc.get('refusal') or r.stdout.strip()
extra = [l for l in unknown(doc, repo) if l not in prose]
print('\n'.join([prose] + extra)); return r.returncode
print('\n'.join(lines))
return 0

Expand Down
12 changes: 9 additions & 3 deletions plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,10 @@
'runtime_observed': 1, # seen in a runtime trace; real, but no call site stands behind it
'multi_inferred': 1, # several declarations fit; each one is a real candidate
'dispatch': 2, # a base method to an override that is actually instantiated
'overload': 0, # the overload signature a call selected runs its set's implementation: one body, certain
'callback_registered': 3, # handed over as a value and invoked by whoever holds it
'event_dispatch': 3, # emitted here, handled there
'asserted': 3, # a link someone recorded (axiomcode link) where the engine resolved nothing: read, not derived
'remote': 5, # a request crosses a process to its handler (remote_edge): no call site names it.
'framework': 5, # a framework runs the other end for this one (framework_edge). Both 5, the default
# impact's route reader already gave them (P.TIER_RANK.get(t, 5)), so its routes do not move
Expand All @@ -67,8 +69,10 @@
'known_edge': 'resolved to one declaration',
'multi_inferred': 'several declarations fit; each is a real candidate',
'dispatch': 'a base method to an override the project instantiates',
'overload': 'the call selected an overload signature; this is the implementation that runs',
'callback_registered': 'handed over as a value and invoked by whoever holds it',
'event_dispatch': 'emitted here, handled there',
'asserted': 'ASSERTED by a link (axiomcode-links.tsv): someone read the call and recorded its target; the engine did not resolve it',
'remote': 'NOT a call site: a request crosses a process to the handler that serves it (transport and destination on the hop)',
'framework': 'NOT a call site: a framework runs the other end for this one (mechanism and registration on the hop)',
'defines': 'NOT a call — written inside that body, so it runs only after it',
Expand Down Expand Up @@ -169,13 +173,14 @@ def legend(tiers):
# as a value, and a capped fan-out exists precisely BECAUSE the candidate set was too large to
# enumerate, so what is in the graph is a sample of it.
DIRECT_CERT = {
'known_edge': 'resolved', 'boundary_lib': 'resolved', 'boundary_generated': 'resolved',
'known_edge': 'resolved', 'boundary_lib': 'resolved', 'boundary_generated': 'resolved', 'overload': 'resolved',
'implicit_constructor': 'resolved', 'written': 'resolved',
'known_implicit_ctor': 'resolved', 'known_builtin_operator': 'resolved', 'runtime_observed': 'resolved',
'multi_inferred': 'one of a set',
'callback_registered': 'registered', 'event_dispatch': 'registered',
'ambient_terminal': 'registered', 'dynamic_terminal': 'registered', 'intrinsic_terminal': 'registered',
'fan_capped': 'capped set',
'asserted': 'asserted', # a link someone recorded (ax_links.py): an edge, never `resolved`
'stub': 'stubs it', # a call inside a mock's stub or verification (stub_sites below): named, never run
'remote': 'remote', 'framework': 'framework', # impact's own rung names for the same two hops (#1469)
}
Expand All @@ -185,6 +190,7 @@ def legend(tiers):
DIRECT_WHY = {
'registered': 'handed over as a value — the engine recorded the hand-off, not a call site',
'capped set': 'calls it, as one of a candidate set too large to enumerate — this is a sample of that set',
'asserted': 'calls it — asserted by a link (axiomcode-links.tsv), not resolved by the engine',
'stubs it': 'stubs it on a mock: the real method does not run there, and the test breaks only if the name or parameters change',
}
# …and where the TIER says something more specific than its certainty. A request or event is not handed over as a
Expand Down Expand Up @@ -263,12 +269,12 @@ def entry_outside(reason):
# instead: the membership is exactly what it was before this table existed, so no row leaves any
# set — only the label it is printed under changes. It matters most for the --delete verdict, where
# dropping a hand-off would turn "something still holds this" into "safe to delete".
EDGE_BACKED = frozenset({'resolved', 'one of a set', 'registered', 'capped set', 'stubs it'})
EDGE_BACKED = frozenset({'resolved', 'one of a set', 'registered', 'asserted', 'capped set', 'stubs it'})

# most certain first. A caller with several call sites to the same callee can hold sites of different
# tiers; a summary that names the caller once takes the best of them, which is the honest reading of
# "at least one resolved call exists here".
DIRECT_ORDER = ('resolved', 'one of a set', 'registered', 'capped set', 'stubs it')
DIRECT_ORDER = ('resolved', 'one of a set', 'registered', 'asserted', 'capped set', 'stubs it')


# ── `defines`: a callable written inside another one's body ────────────────────────────────────────────────
Expand Down
Loading
Loading