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
142 changes: 142 additions & 0 deletions plugins/axiomcode/hooks/_graphline.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
"""What the enrich and changes hooks print about one declaration, where a bare number would mislead.

zero_label a method with no resolved caller. Of 323 caller counts the Read / Grep hooks printed in headless
sessions, 209 were `← 0`, and most of those were methods a framework calls: a route handler, an
`@app.before_request`, a scheduled job, a library override. The graph stores why for most of them,
so the line says it: `entry (http)`, `0 resolved, 3 by name`, `? framework (@Scheduled)`. A method
with no signal at all still reads `0`.
body_line an edit that changed only bodies breaks no caller; the one thing worth saying is which tests reach
it and how to run them. The block it replaces listed 10-42 readers, and 28 of 30 went unused.

Every lookup is one indexed query per declaration (these run on every Read, Grep and Edit)."""
import importlib.machinery, importlib.util, os, sqlite3

# decorations that say nothing about who calls the method: compiler hints, language plumbing, nullness
INERT = {'Override', 'SuppressWarnings', 'Deprecated', 'Serial', 'SafeVarargs', 'FunctionalInterface', 'Nullable',
'NonNull', 'NotNull', 'Nonnull', 'CheckReturnValue', 'VisibleForTesting', 'Generated', 'staticmethod',
'classmethod', 'property', 'abstractmethod', 'cached_property', 'functools.wraps', 'wraps', 'override',
'overload', 'typing.override', 'typing.overload', 'CallerMemberName', 'Obsolete', 'MethodImpl',
'DebuggerStepThrough', 'Pure', 'Data', 'Getter', 'Setter', 'Value', 'Builder', 'ToString',
'EqualsAndHashCode', 'NoArgsConstructor', 'AllArgsConstructor', 'RequiredArgsConstructor', 'Slf4j'}


def _has(con, t):
return con.execute("SELECT 1 FROM sqlite_master WHERE name = ?", (t,)).fetchone() is not None


def _deco(con, owner_id):
"""the first decoration on a declaration that could mean a framework calls it, as written (`@app.before_request`)"""
for name, text in con.execute("SELECT name, text FROM decorations WHERE owner_id = ? ORDER BY line", (owner_id,)):
if (name or '') in INERT or (name or '').rsplit('.', 1)[-1] in INERT: continue
t = (text or '').split('(')[0].strip() or '@' + (name or '')
if t.startswith('[') and not t.endswith(']'): t += ']' # a C# attribute, `[HttpGet("x")]`
return t[:48]
return None


def zero_label(con, mid, name, is_test=0):
"""what `← 0` means for method `mid`, from the strongest signal the graph holds:
entry (<reason>) the runtime invokes it: a route, a test, main, a scheduled job, a listener
? framework (@X) a decoration on it that is not a compiler hint
0 resolved, N by name N call sites write its name on a receiver the engine could not type
? framework (overrides a library method) @Override with no base in this repository
? framework (extends B) its class derives from a base outside the graph (Python)
? framework (@X on Owner) a decoration on its class
0 none of these: nothing in this graph calls it"""
try:
if _has(con, 'entry_points'):
r = con.execute("SELECT reason FROM entry_points WHERE method_id = ? LIMIT 1", (mid,)).fetchone()
if r: return f"entry ({r[0]})"
if is_test: return "entry (test)"
deco = _deco(con, mid) if _has(con, 'decorations') else None
if deco: return f"? framework ({deco})"
if name and _has(con, 'unresolved_sites'):
n = con.execute("""SELECT count(*) FROM call_sites cs JOIN unresolved_sites u ON u.call_site_id = cs.id
WHERE cs.callee_name = ? AND cs.kind NOT IN ('new', 'anon_new', 'DECORATOR_APPLICATION')""",
(name,)).fetchone()[0]
if n: return f"0 resolved, {n} by name"
owner = con.execute("SELECT owner_type_id FROM methods WHERE id = ?", (mid,)).fetchone() if _has(con, 'methods') else None
owner = owner[0] if owner else None
if _has(con, 'decorations') and con.execute(
"SELECT 1 FROM decorations WHERE owner_id = ? AND name = 'Override' LIMIT 1", (mid,)).fetchone() \
and not con.execute("""SELECT 1 FROM overrides o JOIN methods b ON b.id = o.method_id
WHERE o.overriding_method_id = ? AND b.provenance = 'client' LIMIT 1""", (mid,)).fetchone():
return "? framework (overrides a library method)"
if owner and _has(con, 'ext_type_base_unresolved'):
r = con.execute("SELECT c3 FROM ext_type_base_unresolved WHERE c1 = ? LIMIT 1", (owner,)).fetchone()
if r and r[0]: return f"? framework (extends {r[0]})"
if owner and _has(con, 'decorations'):
d = _deco(con, owner)
if d:
on = con.execute("SELECT name FROM types WHERE id = ?", (owner,)).fetchone() if _has(con, 'types') else None
return f"? framework ({d}" + (f" on {on[0]}" if on else '') + ")"
except sqlite3.Error:
pass
return "0"


def distinct_paths(files):
"""the shortest path suffix that tells these files apart: two `StubConverterFactory.java` in different modules print
as `json/StubConverterFactory.java` and `xml/StubConverterFactory.java`, never as the same line twice"""
parts = [f.split('/') for f in files]
out = []
for i, p in enumerate(parts):
k = 1
while k < len(p) and any(j != i and q[-k:] == p[-k:] for j, q in enumerate(parts)): k += 1
out.append('/'.join(p[-k:]))
return out


_TI = None
def _command_for(lang, files, classes):
"""the runnable command `axiomcode test-impact` prints, from the same function"""
global _TI
if _TI is None:
p = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'skills', 'axiomcode', 'scripts', 'axiomcode-test-impact')
try:
ld = importlib.machinery.SourceFileLoader('ax_test_impact', p)
m = importlib.util.module_from_spec(importlib.util.spec_from_loader('ax_test_impact', ld)); ld.exec_module(m)
_TI = m.command_for
except Exception:
_TI = lambda *a: None
try: return _TI(lang, files, classes)
except Exception: return None


LANG = {'.java': 'java', '.py': 'python', '.ts': 'typescript', '.tsx': 'typescript', '.js': 'javascript',
'.mjs': 'javascript', '.cjs': 'javascript', '.jsx': 'javascript', '.cs': 'csharp'}
SHOWN = 6


def body_line(db, results):
"""ONE line for an edit that changed only bodies: which declarations, how many tests reach them, how to run those.
`results` is [(changed-declaration, impact-json)], the same pairs the blast-radius block is built from."""
names = [d['symbol'] for d, _ in results]
ids, owners, files = set(), set(), set()
for d, j in results:
ids |= set((j or {}).get('test_ids') or [])
for t in (j or {}).get('tests') or []: # the rules' answer carries names, not ids
if not (j or {}).get('_sql') and t.get('owner'): owners.add(t['owner'])
if not (j or {}).get('_sql') and t.get('at'): files.add(t['at'].split(':')[0])
n = len(ids) or sum(len((j or {}).get('tests') or []) for _, j in results)
if ids:
try:
con = sqlite3.connect(f'file:{db}?mode=ro', uri=True)
idl = sorted(ids)
for i in range(0, len(idl), 400):
ch = idl[i:i + 400]
for o, f in con.execute(f"SELECT owner, file FROM symbols WHERE id IN ({','.join('?' * len(ch))})", ch):
if o: owners.add(o)
if f: files.add(f)
con.close()
except sqlite3.Error:
pass
what = ', '.join(names[:3]) + (f" +{len(names) - 3}" if len(names) > 3 else '')
if not n:
return f"graph: body edit of {what}: no test reaches it through the graph (a lower bound)"
lang = LANG.get(os.path.splitext(results[0][0].get('file', ''))[1], '')
fl, cl = sorted(files), sorted(owners)
cmd = _command_for(lang, fl[:SHOWN], cl[:SHOWN])
more = (len({c.split('.')[-1] for c in cl}) if lang in ('java', 'csharp') and cl else len(fl)) - SHOWN
tail = (f"; run: {cmd}" + (f" (+{more} more: axiomcode test-impact)" if more > 0 else '')) if cmd else "; axiomcode test-impact gives the command"
return f"graph: body edit of {what}: {n} test(s) reach it{tail}"
27 changes: 17 additions & 10 deletions plugins/axiomcode/hooks/changes.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'skills', 'axiomcode', 'scripts'))
import graph_sql
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import _host
import _host, _graphline

ev = _host.read(); event = ev.get('hook_event_name', ''); tool = ev.get('tool_name', ''); inp = ev.get('tool_input', {}) or {}; cwd = ev.get('cwd') or os.getcwd()
if not os.path.exists(os.path.join(cwd, '.axiomcode', 'out', 'graph.sqlite')): sys.exit(0)
Expand All @@ -43,7 +43,10 @@ def changed(args, timeout=12):
except Exception: return {}

def summarize(decls, head, contract_kinds=('signature', 'field', 'type', 'removed')):
"""the blast radius of up to three changed declarations, a few lines each"""
"""the blast radius of up to three changed declarations, a few lines each; `head` is formatted with {n}, how many.
A BODY-ONLY change breaks no caller, so it gets no blast radius: one line of the tests that reach it and the command
that runs them (_graphline.body_line), after the declarations that can break something."""
body = [d for d in decls if d['kind'] == 'body']; decls = [d for d in decls if d['kind'] != 'body']
def impact(d):
"""SQL first. The Datalog run this replaced was a median 6.94 s on a 1.2M-LOC bundle, p90 23.8 s, and 17 of 38
randomly sampled methods blew the 14 s timeout below — so on roughly half of real edits the hook printed
Expand All @@ -52,13 +55,14 @@ def impact(d):
It returns None for what it does not cover (a constructor, whose callers are instantiations rather than call
edges); that falls through to impact.dl, which is still right for those."""
try:
j = graph_sql.impact_shaped(cwd, d['target'])
j = graph_sql.impact_shaped(cwd, d.get('shown_target') or d['target'])
if j is not None: return d, j
except Exception: pass
try: return d, json.loads(subprocess.run([sys.executable, os.path.join(SCR, 'axiomcode-impact'), d['target'], cwd, '--json', '--depth', '12'] + (['--kind', d['target_kind']] if d.get('target_kind') and d['target_kind'] != 'param' and '(' not in d['target'] else []), capture_output=True, text=True, timeout=14).stdout or '{}')
try: return d, json.loads(subprocess.run([sys.executable, os.path.join(SCR, 'axiomcode-impact'), d.get('shown_target') or d['target'], cwd, '--json', '--depth', '12'] + (['--kind', d['target_kind']] if d.get('target_kind') and d['target_kind'] != 'param' and '(' not in d['target'] else []), capture_output=True, text=True, timeout=14).stdout or '{}')
except Exception: return d, {}
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as ex: results = list(ex.map(impact, decls[:3]))
lines = [head]
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as ex:
results = list(ex.map(impact, decls[:3])); bodies = list(ex.map(impact, body[:3]))
lines = [head.format(n=len(decls))] if decls else []
# ordered as ax_edges.DIRECT_ORDER and impact's CERT are: an edge the engine asserted outranks a
# name or a text match, and neither a hand-off nor a truncated fan-out outranks a resolved call.
rank = {'resolved': 0, 'one of a set': 1, 'registered': 2, 'capped set': 3, 'in scope': 4, 'by name': 5, 'text': 6}
Expand Down Expand Up @@ -98,14 +102,17 @@ def impact(d):
if reads:
lines.append(f" reads / uses it ({len(reads)}): " + names(reads) if not j.get('_sql')
else f" reads / uses it — resolved callers: " + names(reads)
+ f" (the fast path; `axiomcode impact {d['target']}` adds the by-name, in-scope and text layers)")
+ f" (the fast path; `axiomcode impact {d.get('shown_target') or d['target']}` adds the by-name, in-scope and text layers)")
# WHICH SIDE ANSWERED, in one word. The two paths give different answers by design — the fast path reads
# call_edges and the rules add the by-name, in-scope and text layers — so a count nobody can attribute is a
# count nobody can check. This cost a whole re-derivation once: three declarations reported 0 reached and
# 0 tests where the rules report ~1800 and ~1470, and there was no way to tell from the block whether that
# was the fast path answering, the rules answering, or the CLI having given up.
lines.append(f" [{'fast path' if j.get('_sql') else 'rules'}] reaches {len(rc)} more callable(s) through resolved calls within 12 hops; {len(ts)} test(s) reach the change" + (": " + ', '.join(f"{t['owner'] or (t.get('at') or '').rsplit('/', 1)[-1].split(':')[0] or 'test'}::{t['name']}" for t in ts[:3]) + (' …' if len(ts) > 3 else '') if ts else '') + (f"; {j['unresolved_inside']} unresolved call(s) inside — a lower bound" if j.get('unresolved_inside') else ''))
if len(decls) > 3: lines.append(f" … +{len(decls) - 3} more: axiomcode changed --impact")
if bodies:
lines.append(_graphline.body_line(os.path.join(os.environ.get('AXIOMCODE_GRAPH') or os.path.join(cwd, '.axiomcode'), 'out', 'graph.sqlite'),
bodies + [(d, {}) for d in body[3:]]))
return lines

def key(d): return f"{d['file']}:{d['symbol']}:{d['kind']}:{d.get('detail', '')}"
Expand All @@ -125,7 +132,7 @@ def key(d): return f"{d['file']}:{d['symbol']}:{d['kind']}:{d.get('detail', '')}
j = changed(['--old', fp, '--new', tmp, '--file', rel]); os.unlink(tmp)
risky = [d for d in j.get('changed', []) if d.get('target') and d['kind'] in ('signature', 'field', 'type', 'removed')]
if risky:
lines = summarize(risky, f"graph: this edit is about to change {len(risky)} declaration(s) in {rel} in a way that reaches callers — before it lands:")
lines = summarize(risky, "graph: this edit is about to change {n} declaration(s) in " + rel.replace('{', '{{').replace('}', '}}') + " in a way that reaches callers — before it lands:")
st = load_state(); st['reported'] = list(dict.fromkeys(st.get('reported', []) + [key(d) for d in risky])); save_state(st)
elif event in ('PostToolUse', 'UserPromptSubmit'):
# after the fact, an edit is measured against the baseline graph, where what it removed still has its callers;
Expand All @@ -145,14 +152,14 @@ def key(d): return f"{d['file']}:{d['symbol']}:{d['kind']}:{d.get('detail', '')}
st = load_state(); seen = set(st.get('reported', []))
new = [d for d in j.get('changed', []) if d.get('target') and key(d) not in seen and not TEST.search(d['file'])]
if new:
lines = summarize(new, f"graph: after that command, {len(new)} declaration(s) changed in the working tree (against the graph's commit {(j.get('built_at') or '')[:10]}) —")
lines = summarize(new, f"graph: after that command, {{n}} declaration(s) changed in the working tree (against the graph's commit {(j.get('built_at') or '')[:10]}) —")
st['reported'] = list(seen | {key(d) for d in new}); save_state(st)
elif event == 'UserPromptSubmit':
j = changed([], timeout=18)
st = load_state(); seen = set(st.get('reported', []))
new = [d for d in j.get('changed', []) if d.get('target') and key(d) not in seen and not TEST.search(d['file'])]
if new:
lines = summarize(new, f"graph: {len(new)} declaration(s) changed in the working tree since the graph's commit {(j.get('built_at') or '')[:10]} and were not reported yet —")
lines = summarize(new, f"graph: {{n}} declaration(s) changed in the working tree since the graph's commit {(j.get('built_at') or '')[:10]} and were not reported yet —")
st['reported'] = list(seen | {key(d) for d in new}); save_state(st)
try:
with open(os.path.join(cwd, '.axiomcode', 'hooks.jsonl'), 'a') as f: f.write(json.dumps({'event': event, 'tool': tool, 'lines': len(lines), 'chars': sum(len(l) for l in lines), 'input': {k: v for k, v in inp.items() if k in ('file_path', 'command', 'old_string', 'new_string')}, 'text': '\n'.join(lines)}) + '\n')
Expand Down
Loading
Loading