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
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
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
8 changes: 6 additions & 2 deletions plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
'dispatch': 2, # a base method to an override that is actually instantiated
'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 @@ -69,6 +70,7 @@
'dispatch': 'a base method to an override the project instantiates',
'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 @@ -176,6 +178,7 @@ def legend(tiers):
'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 +188,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 +267,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
6 changes: 6 additions & 0 deletions plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py
Original file line number Diff line number Diff line change
Expand Up @@ -1505,6 +1505,12 @@ def query(repo, verb, argv, fresh=False):
"""run a query verb (argv) against the last good graph, the stale-while-revalidate way (above). Returns its exit code"""
import ax_exec
argv = ax_exec.program(argv) # `python3` may be a shell shim no native process can start (#1331)
# THE ASSERTED LINKS FOLLOW THEIR FILE (ax_links.py): a links file edited by hand, pulled or removed since the graphs
# were last given it is re-applied here, O(links), with the derived facts patched in place — never a rebuild
try:
import ax_links; ax_links.sync(repo)
except Exception:
pass
def run():
return subprocess.run(argv, stdout=subprocess.PIPE)
def passthrough(): ax_exec.become(argv) # never os.execvp: on Windows it returns 0 before the answer (#1640)
Expand Down
Loading
Loading