diff --git a/README.md b/README.md index c9153fa3..c388a185 100644 --- a/README.md +++ b/README.md @@ -255,8 +255,8 @@ looked up again in the graph before you see it; the `verified:` line is that che ### Support for agents -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. +Every agent below gets the four MCP tools and the skill; the hooks, which keep the graph current and report what an +edit breaks, run where the last column says so. No hook annotates the agent's own reads and searches. | Agent | Install | Uninstall | Hooks | |---|---|---|---| diff --git a/hooks/hooks.json b/hooks/hooks.json index 160dad33..8f6c0828 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -18,12 +18,6 @@ } ], "AfterTool": [ - { - "matcher": "read_file|grep_search|glob|run_shell_command|replace|write_file", - "hooks": [ - { "type": "command", "command": "node \"${extensionPath}${/}plugins${/}axiomcode${/}hooks${/}run.js\" enrich.py", "timeout": 20000 } - ] - }, { "matcher": "run_shell_command", "hooks": [ diff --git a/plugins/axiomcode/AGENTS.md b/plugins/axiomcode/AGENTS.md index f03f5c60..f85c960e 100644 --- a/plugins/axiomcode/AGENTS.md +++ b/plugins/axiomcode/AGENTS.md @@ -1,8 +1,8 @@ # axiomcode -Search with grep and Read as usual: after a grep, the call graph adds only what grep cannot know (which -declaration each match reaches, the callers that never spell the name). Ask it directly, through the axiomcode -MCP tools, for what no text search answers: +Search with grep and Read as usual; the call graph stays out of the way until you ask it. Ask it directly, +through the axiomcode MCP tools, for what no text search answers — which declaration a call reaches, the callers +that never spell the name: impact(name) who calls it, what a change to it reaches, and its tests; impact() with no name: the same for your uncommitted edits diff --git a/plugins/axiomcode/hooks/_graphline.py b/plugins/axiomcode/hooks/_graphline.py index be03b5bd..35f604a5 100644 --- a/plugins/axiomcode/hooks/_graphline.py +++ b/plugins/axiomcode/hooks/_graphline.py @@ -1,4 +1,4 @@ -"""What the enrich and changes hooks print about one declaration, where a bare number would mislead. +"""What the changes hook prints 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 diff --git a/plugins/axiomcode/hooks/changes.py b/plugins/axiomcode/hooks/changes.py index 26ccd942..76571ad5 100644 --- a/plugins/axiomcode/hooks/changes.py +++ b/plugins/axiomcode/hooks/changes.py @@ -3,7 +3,7 @@ PreToolUse Edit / Write / MultiEdit the edit is applied to a copy of the file; when it changes a SIGNATURE, a FIELD's type, a TYPE header, or removes a declaration, the blast radius is given BEFORE the - file changes (a body-only edit is reported after, by enrich.py — nothing breaks) + file changes (a body-only edit breaks no caller; it is reported on the next prompt) PostToolUse Bash a command that can modify sources (sed -i, patch, git apply / checkout / pull / merge / stash pop / cherry-pick / revert, a redirect into a source file, a script run): the whole working tree against the graph's commit, the declarations not reported yet diff --git a/plugins/axiomcode/hooks/direct.py b/plugins/axiomcode/hooks/direct.py deleted file mode 100755 index 58ca74d4..00000000 --- a/plugins/axiomcode/hooks/direct.py +++ /dev/null @@ -1,205 +0,0 @@ -#!/usr/bin/env python3 -"""PreToolUse on Read|Grep|Glob|Bash (and the graph's own MCP tools, which silence it): say it at the moment the alternative is about to run. - -#1100 measured three changes to what this skill SAYS and none of them moved the number: the body cut by -72%, the description rewritten into the words a task is phrased in, and orient.py's first turn turned from -bare directory names into ranked entry points. `graph_calls` stayed 0 in 17 of 18 runs. What those three -have in common is WHERE they speak: a surface the agent reads once, before it has a question. - -This is the other axis. Not better wording — the same claim, placed at the only moment it competes with -anything: immediately before a raw search or read, which is the action it is asking to come second. - -Rules it holds itself to, in the spirit of orient.py: - · SILENT WITHOUT A GRAPH. No graph.sqlite means the verbs cannot answer, and a directive toward a tool - that has nothing to say is pure noise. This is the difference between a directive and a nag. - · SILENT WHEN THE AGENT IS ALREADY DOING IT. A Bash call that IS an axiomcode verb gets nothing. - · NEVER BLOCKS. additionalContext, exit 0. The agent keeps its own judgement; the point is that the - judgement is made with the option in view, not that the option wins. - · ONCE PER SESSION, WHEREVER THE SESSION GOES. Said once, then never again. It used to repeat as one line before - every later Read, Grep and Bash; then it was stamped once per session PER REPOSITORY, inside that repository's - .axiomcode/, so an agent moving between a worktree, its fixtures and a corpus app heard the full text in each, - and one whose .axiomcode/ it could not write heard it before every search. The stamp now lives in the temp - directory, keyed by the session alone. - · ONLY WHEN IT CAN NAME WHAT THIS SEARCH IS ABOUT. The generic text was spoken before the first search whatever - it searched for, and a feed audit found it acted on 0% of the time: advice about "a name" in front of a search - for `TODO` or `'use strict'` is not advice about this search. It now speaks only before a search whose pattern - names a class, function or method the graph declares, names it with where it is, and gives the one call that - answers what the grep is asking. A Read of a file already chosen and a Glob for files are not that question. - · ONLY WHERE THE CHOICE IS. A Bash call that searches source competes with the graph; `git`, `ls`, a build, a - test run, or a grep filtering another command's output does not, and a directive in front of one is noise. -""" -import hashlib, json, os, re, sys -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -import _host, _where - -# the shell commands that are the search this directive competes with, and the pattern each one searches for -SEARCH = re.compile(r'(^|[;&|(]\s*|\s)(grep|egrep|rg|ag|ack|git\s+grep)\b') -PATTERN = re.compile(r'\b(?:grep|egrep|rg|ag|ack|git\s+grep)\b((?:\s+-[-\w=]+)*)\s+(?:-e\s+)?([\'"]?)(.+?)\2(?:\s|$)') -# the declarations a search can be asking about: a callable or a type. A field, a constant or a local shares its name -# with too much prose and config to be the thing a grep for that word is after. -KINDS = ('class', 'interface', 'enum', 'record', 'struct', 'trait', 'function', 'method', 'constructor') -SPREAD = 5 -COMMON = re.compile(r'(the|and|for|new|return|null|true|false|this|self|void|int|String|public|private|static|import|export|const|function|class|def)') - - -def stamp_path(sid, cwd=''): - """one file per session, outside every repository: a repository's .axiomcode/ is per tree, gets rebuilt, and is not - always writable, and each of those made the directive say itself again. A host that sends no session id is keyed - on the directory it runs in instead, so every such session does not share one stamp and hear it once, ever.""" - tmp = os.environ.get('TMPDIR') or os.environ.get('TEMP') or os.environ.get('TMP') or ('/tmp' if os.name != 'nt' else os.path.expanduser('~')) - key = str(sid) if sid else 'cwd-' + hashlib.sha1(os.path.realpath(cwd or '.').encode()).hexdigest()[:16] - return os.path.join(tmp, 'axiomcode-hooks', 'directed-' + (re.sub(r'[^\w.-]', '_', key)[:80] or 'x')) - - -def mark(stamp): - try: - os.makedirs(os.path.dirname(stamp), exist_ok=True) - open(stamp, 'w').close() - except Exception: - pass - - -def identifiers(pat): - """the names a search pattern spells: every branch of an alternation, regex syntax stripped, at most 6""" - out = [] - for br in re.split(r'\\\||(?)$', br[:m.start()])): - continue - out.append(n) - return out[:6] - - -def declared(root, names, scope=()): - """[(name, display, file, line)] for the names a graph in `root` declares as a callable or a type: the declaration - inside the searched files or directories (`scope`, relative to root) first, then the first in the repository. A - name declared in more than SPREAD places (`get`, `run`, `handle`) says nothing about which one this search is for, - and naming one of them is a guess, so it is left out.""" - import sqlite3 - ax = os.path.join(root, '.axiomcode') - dbs = [os.path.join(ax, 'out', 'graph.sqlite')] - try: - dbs += [os.path.join(ax, 'lang', l, 'out', 'graph.sqlite') for l in sorted(os.listdir(os.path.join(ax, 'lang')))] - except OSError: - pass - found = {} - ph = ','.join('?' * len(KINDS)) - for db in dbs: - if len(found) == len(names) or not os.path.isfile(db): - continue - try: - con = sqlite3.connect(f'file:{db}?mode=ro', uri=True) - for n in names: - if n in found: - continue - rows = con.execute(f"SELECT display, file, line FROM symbols WHERE name = ? AND kind IN ({ph}) " - f"ORDER BY is_test, file, line LIMIT {SPREAD + 1}", (n, *KINDS)).fetchall() - if not rows or len(rows) > SPREAD: - continue - inside = [r for r in rows if any(r[1] == s or r[1].startswith(s.rstrip('/') + '/') for s in scope)] - r = (inside or rows)[0] - found[n] = (n, r[0], r[1], r[2]) - con.close() - except Exception: - continue - return [found[n] for n in names if n in found] - - -def directive(hits): - """what to say before a search for these declarations: each named, with the call that answers what the grep asks""" - first = hits[0] - at = f"{first[2]}:{first[3]}" - named = ', '.join(f"`{h[1]}` ({h[2]}:{h[3]})" for h in hits[:3]) - # short on purpose: it is read once and then re-read on every later turn - return ( - f"graph: this search is for {named}. Who calls it and what a change breaks, each with its code,\n" - f" including the callers that never spell the name (an interface, an override, a callback, DI):\n" - f" impact(name=\"{at}\") (mcp__plugin_axiomcode_axiomcode__impact; shell `axiomcode impact {at}`). Also\n" - f" path(start, end) for how A reaches B (`axiomcode path A B`). Said once this session." - ) - - -def main(): - ev = _host.read() - tool = ev.get('tool_name') or '' - inp = ev.get('tool_input') or {} - stamp = stamp_path(ev.get('session_id'), ev.get('cwd') or os.getcwd()) - - # the agent is already reaching for the graph -- saying it again is the nag this is trying not to be, - # and once it has, the directive has nothing left to say this session, in any repository - if 'axiomcode' in tool or (tool == 'Bash' and 'axiomcode' in (inp.get('command') or '')): - mark(stamp) - sys.exit(0) - if os.path.exists(stamp): - sys.exit(0) - - # only a search has a pattern that can name what it is looking for; a Read of a chosen file or a Glob does not - here = ev.get('cwd') or os.getcwd() - if tool == 'Grep': - pat = inp.get('pattern') or '' - paths = [inp['path']] if inp.get('path') else [] - elif tool == 'Bash': - cmd = inp.get('command') or '' - m = SEARCH.search(cmd) and PATTERN.search(cmd) - if not m: - sys.exit(0) - # after a `|` a grep filters another command's output (`npm test | grep FAIL`), which is not a code search - seps = re.findall(r'\|\||&&|;|\n|\|', cmd[:m.start()]) - if seps and seps[-1] == '|': - sys.exit(0) - # a search whose every file is not source (`grep x NOTES.md`, `rg y run.log`) is not the decision this is about - _, paths = _where.bash_where(cmd, here) - files = [p for p in paths if os.path.isfile(p)] - if files and not any(_where.is_source(p) for p in files): - sys.exit(0) - pat = m.group(3) - else: - sys.exit(0) - - names = identifiers(pat) - if not names: - sys.exit(0) - # the repository whose graph would answer: the one above the file or directory this tool is about to touch, not - # the session's working directory, which in 308 of 309 measured sessions held no graph (_where.py) - cwd = _where.locate(tool, inp, here, ev.get('session_id')) - if not cwd: - sys.exit(0) - real = os.path.realpath(cwd) - scope = [os.path.relpath(os.path.realpath(os.path.join(here, p)), real) for p in paths] - hits = declared(cwd, names, [s for s in scope if s != '.' and not s.startswith('..')]) - if not hits: - sys.exit(0) - - mark(stamp) - text = directive(hits) - # stream-json does not carry additionalContext, so a run cannot show from its transcript that this - # fired or what it said -- and #1100 is a question about exactly that. enrich.py already logs itself - # next to the graph for the same reason; this writes the same file, so one reader sees both halves. - try: - with open(os.path.join(cwd, '.axiomcode', 'hooks.jsonl'), 'a') as f: - f.write(json.dumps({'hook': 'direct', 'tool': tool, 'chars': len(text), 'named': [h[1] for h in hits], - 'input': {k: v for k, v in inp.items() - if k in ('file_path', 'pattern', 'command')}}) + '\n') - except OSError: - pass - _host.emit('PreToolUse', text) - - -# THIS HOOK RUNS BEFORE EVERY Read, Grep, Glob AND Bash THE AGENT MAKES. A hook that raises on one of them -# costs that agent the turn, in a repository whose only fault is having a graph. Nothing it does is worth a -# failed tool call, so every path out of it is exit 0: a directive is an optional courtesy, not a dependency. -if __name__ == '__main__': - try: - main() - except Exception: - pass - sys.exit(0) diff --git a/plugins/axiomcode/hooks/enrich.py b/plugins/axiomcode/hooks/enrich.py deleted file mode 100755 index f99c67cc..00000000 --- a/plugins/axiomcode/hooks/enrich.py +++ /dev/null @@ -1,613 +0,0 @@ -#!/usr/bin/env python3 -"""PostToolUse on Read / Grep: the agent used its own action; the graph adds what it knows about what came back, for free. - Read [offset, limit] → for the callables declared in the lines read, the edges the text cannot show: callers and - callees in other files or in this file outside the range (with their line), overrides in - other files or in this file's inner / enum classes, unresolved calls. Counts by default, - names for 1–3 callers or when the other end was read earlier this session (★, first); - the rest as one `+N more` line. ≤ 6 lines - Grep → when the pattern is an identifier: its declarations, with callers and callees - -Short on purpose (≤ 10 lines, names not bodies): the transcripts showed pasted context makes runs longer, so this says only -what a graph knows and a file does not — the edges. Nothing when the repo has no graph, or the read is not source.""" -import collections, json, os, re, sqlite3, subprocess, sys -sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'skills', 'axiomcode', 'scripts')) -import graph_sql, ax_contract as _ax -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -import _host, _graphline, _where - -ev = _host.read(); tool = ev.get('tool_name', ''); inp = ev.get('tool_input', {}) or {}; scwd = ev.get('cwd') or os.getcwd() -# THE GRAPH IS FOUND FROM WHAT THE TOOL TOUCHED, not from the session's working directory: an agent reads and greps -# indexed trees by absolute path from a directory that has no graph (308 of 309 measured sessions), and a hook that -# looked only in the working directory never spoke. `cwd` below is the repository ROOT whose graph answers; `scwd` is -# where the session is, which a relative path in the tool's input is relative to. -cwd = _where.locate(tool, inp, scwd, ev.get('session_id')) -if not cwd: sys.exit(0) -_file = next((p for p in _where.touched(tool, inp, scwd) if os.path.isfile(p)), None) -os.environ.update(_where.lang_env(cwd, _file)) # a file in a language with its own graph is answered from it -def rel_of(fp): - """the graph stores repo-relative paths; the tool's file_path may reach the tree through a symlink while cwd is resolved (or the - reverse) — compare real paths, and if the file still is not under the tree, fall back to the graph's own suffix match""" - fp = str(fp) - for a, b in ((fp, cwd), (os.path.realpath(fp), os.path.realpath(cwd)), (os.path.realpath(fp), cwd), (fp, os.path.realpath(cwd))): - try: r = os.path.relpath(a, b) - except ValueError: continue # Windows: a file on another drive is not under the tree - if not r.startswith('..'): return r.replace(os.sep, '/') # the index stores '/' on every platform - return fp -db = _where.graph_db(cwd, _file) -if not os.path.exists(db): sys.exit(0) -con = sqlite3.connect(db); con.row_factory = sqlite3.Row -q = lambda s, *p: con.execute(s, p).fetchall() -if not q("SELECT 1 FROM sqlite_master WHERE name='symbols'"): sys.exit(0) - -# What is this session actually trying to do? Written once by the orientation hook. Everything below ranked -# declarations by degree -- callers plus unresolved calls -- which answers "which of these is most connected" -# when the question was "which of these is about my task". In a measured run the agent opened the right file -# six times, and the one function its fix needed was listed last in an overflow line behind two that were not -# in the fix at all, because those two had more edges. -TASK = [] -try: - # _ax is imported at module level beside graph_sql, NOT here: it is used on the main output path - # (is_synthetic) and task.txt is optional, so binding it inside this try made every enrichment depend - # on an optional file being present -- absent, the open() raised and the name was never bound (#1035). - _t = open(os.path.join(cwd, '.axiomcode', 'task.txt')).read() - TASK = _ax.task_terms(_ax.task_text(_t))[:40] -except Exception: - TASK = [] -_TSET = set(TASK) - -def task_hits(display): - """Which of the task's words this declaration's name carries.""" - if not _TSET: return [] - try: toks = set(_ax.subtokens(display or '')) - except Exception: return [] - out = [t for t in TASK if t in toks or any(x.startswith(t) and len(t) >= 4 for x in toks)] - return out[:2] - -# where the agent IS: the callables it read most recently (per session, last 6 reads). A later grep for a common name is -# read against them — the `close` that the method you were just reading calls is the one you mean -STATE = os.path.join(cwd, '.axiomcode', f"hooks-state-{ev.get('session_id', 'x')}.json") -def load_state(): - try: return json.load(open(STATE)) - except Exception: return {'reads': []} -def save_state(st): - try: json.dump(st, open(STATE, 'w')) - except OSError: pass - -# A GRAPH ANSWER THE AGENT ALREADY HAS IS NOT REPEATED. `context` / `path` / `impact` print the declarations they are about -# as `file:line`; a Read of one of them right after would get the same callers and callees again as a `graph:` block. Those -# declarations are marked annotated, so a later block carries only what the answer did not. Nothing is emitted here. -_cmd = str(inp.get('command', '')) if tool == 'Bash' else '' -_from_shell = False # a grep run through Bash: matched as whole names only -if tool.startswith('mcp__plugin_axiomcode_') or re.match(r'\s*(?:\S*/)?axiomcode(?:-\w+)?\s+(context|path|impact|changed|test-impact)\b', _cmd): - text = json.dumps(ev.get('tool_response', '')) - locs = set(re.findall(r'([\w./-]+\.\w+):(\d+)', text)) - ids = [] - for f, ln in locs: - ids += [r['id'] for r in q("SELECT id FROM symbols WHERE (file = ? OR file LIKE ?) AND line = ? AND method_id IS NOT NULL", f, '%/' + f.lstrip('/'), int(ln))] - if ids: - st = load_state(); st['annotated_ids'] = list(dict.fromkeys(st.get('annotated_ids', []) + ids)); save_state(st) - sys.exit(0) - -# a grep / sed / cat run through Bash is the same action — in a session where the Grep tool is deferred, that is what the agent does -if tool == 'Bash': - c = str(inp.get('command', '')) - m = re.search(r'\b(?:grep|rg|ag|git\s+grep)\b((?:\s+-[-\w=]+)*)\s+(?:-e\s+)?([\'"]?)(.+?)\2(?:\s|$)', c) - # a search of ANOTHER tree says nothing about this graph: whatever it names is a same-named stranger here. - # The tree searched is where the shell is (a `cd` before the grep) and the paths given after the pattern. - if m: - import shlex - base, _ = _where.bash_where(c[:m.start()], scwd) # where the shell is when the grep runs (a `cd` first) - def outside(p): - r = os.path.relpath(os.path.realpath(_where._abs(p, base)), os.path.realpath(cwd)) - return r == '..' or r.startswith('..' + os.sep) - try: rest = shlex.split(c[m.end():].split('|')[0].split('&&')[0].split(';')[0]) - except ValueError: rest = [] - where = [a for a in rest if not a.startswith('-') and ('/' in a or a.startswith(('~', '.')))] - if outside(base) or any(outside(a) for a in where): sys.exit(0) - # A GREP THAT IS NOT A CODE SEARCH SAYS NOTHING ABOUT THIS GRAPH (#1604). After a `|` it filters another command's - # output (`npm test | grep -E 'FAIL|ok'` named `Result.fail` and a test helper `fail`); over files no graph - # indexes (a log, a shell script, YAML) it names whatever shares a word with the pattern. - seps = re.findall(r'\|\||&&|;|\n|\|', c[:m.start()]) - if seps and seps[-1] == '|': sys.exit(0) - files = [a for a in rest if not a.startswith('-')] - if files and all(os.path.splitext(a)[1] and os.path.splitext(a)[1].lower() not in _graphline.LANG for a in files): sys.exit(0) - if m: tool = 'Grep'; inp = {'pattern': m.group(3)}; _from_shell = True - else: - m = re.search(r"sed -n '?(\d+),(\d+)p'? (\S+)", c) or re.search(r'\bcat\s+(\S+\.(?:' + _where.SOURCE_ALT + r'))\b', c) - # a relative file is relative to where the shell is when it runs: the session's directory, or a `cd` before it - if m: base, _ = _where.bash_where(c[:m.start()], scwd) - if m and m.re.groups == 3: tool = 'Read'; inp = {'file_path': _where._abs(m.group(3), base), 'offset': int(m.group(1)), 'limit': int(m.group(2)) - int(m.group(1)) + 1} - elif m: tool = 'Read'; inp = {'file_path': _where._abs(m.group(1), base)} - else: sys.exit(0) - if _where.root_of(inp['file_path']) != cwd: sys.exit(0) # the file read is another tree's, or none's - -# names are looked up by prefix on every grep: an index on symbols.name keeps that under 0.1 s (created once, harmless if present) -try: con.execute("CREATE INDEX IF NOT EXISTS symbols_name ON symbols(name)"); con.commit() -except Exception: pass - -# how many tests reach this method, answered ON DEMAND from graph.sqlite rather than from a precomputed table. -# The table was built by summary.dl, an all-sources closure over every test and entry point: on a 1.23M-LOC Java -# bundle that is up to 15,789 tests x 25,154 methods, and it never finished — 594 s of CPU and then its own 600 s -# timeout, with or without a compiled binary, so the counts never existed on a graph large enough to want them. -# Seeded from one method the same closure is small, and the cap keeps a hub method flat. Each edge table joins in -# its OWN recursive branch so SQLite drives them by index; one combined edge CTE rescans every edge per call. -REACH_DEPTH = 6 -GREP_HIT = re.compile(r'(?:^|[\s"\'])((?:[\w.@-]+/)*[\w.@-]+\.\w+)(?::(\d+))?') -WORD = lambda n: re.compile(r'(? 'module'", n) - if not decls: continue - mids = {d['method_id']: d for d in decls} - ph = ','.join('?' * len(mids)) - edges = q(f"SELECT cs.file_path f, cs.start_line ln, coalesce(cs.end_line, cs.start_line) eln, cs.callee_name cn, e.callee_method_id m, cr.display who FROM call_edges e " - f"JOIN call_sites cs ON cs.id = e.call_site_id JOIN symbols cr ON cr.id = e.caller_id " - f"WHERE e.callee_method_id IN ({ph})", *mids) - # 1. the calls grep matched, split by the declaration they reach — only when the name is declared more than once - # A call through an interface reaches the base and every implementation: that is one target set, not an ambiguity. - # Only lines that reach DIFFERENT sets are worth telling apart, because that is what grep's lines cannot show. - split = '' - if len(decls) > 1: - mine = [x for x in edges if (x['f'], x['ln']) in hit_lines] if hit_lines else [x for x in edges if x['f'] in hit_files and x['cn'] == n] - per_line = collections.defaultdict(set) - for x in mine: per_line[(x['f'], x['ln'])].add(mids[x['m']]['display']) - groups = collections.defaultdict(list) - for k, ds in per_line.items(): groups[frozenset(ds)].append(k) - if len(groups) > 1: - def name(ds): return min(ds, key=len) + (f" (+{len(ds) - 1} implementation(s))" if len(ds) > 1 else '') - def at(ks): - ks = sorted(ks); fs = collections.OrderedDict() - for f, ln in ks: fs.setdefault(f.split('/')[-1], []).append(str(ln)) - return ' '.join(f"{f}:{','.join(v[:3])}" for f, v in list(fs.items())[:2]) + (' …' if len(fs) > 2 else '') - split = '; '.join(f"{len(ks)} → {name(ds)} [{at(ks)}]" for ds, ks in sorted(groups.items(), key=lambda kv: -len(kv[1]))[:3]) - # 2. the callers no text search finds: the call's line does not spell the name - hidden = [] - for x in edges: - # a call spans lines (`rows\n .sort(byPath)` starts a line above its name): the whole span is checked - src = [linecache_line(x['f'], i) for i in range(x['ln'], max(x['ln'], x['eln']) + 1)] - if None not in src and not any(WORD(n).search(l) for l in src) and (x['f'], x['ln']) not in hit_lines: - hidden.append(f"{x['f']}:{x['ln']} ({x['who'].split('.')[-1]})") - hidden = list(dict.fromkeys(hidden)) - # 3. a declaration nothing in the code calls, that the runtime enters (a route, a schedule, a framework - # annotation): grep shows no caller and reads as dead code; this says who calls it. A plain `0` stays unsaid, - # since grep's lines already show it - entered = [] - if not edges: - for d in decls: - is_test = (q("SELECT is_test FROM symbols WHERE method_id = ? LIMIT 1", d['method_id']) or [{'is_test': 0}])[0]['is_test'] - lab = _graphline.zero_label(con, d['method_id'], None, is_test or 0) - if lab.startswith(('entry', '?')) and lab != 'entry (test)': - entered.append(f"{d['display']} {lab.replace('? framework', 'by the framework').replace('entry', 'entered by the runtime')}") - if not split and not hidden and not entered: continue - parts = [] - if entered: parts.append("nothing in the code calls " + '; '.join(entered[:3])) - if split: parts.append(f"your matches reach different declarations: {split}") - if hidden: parts.append(f"{len(hidden)} caller(s) grep cannot see (the line never names it): " + ', '.join(hidden[:4]) + (f" +{len(hidden) - 4}" if len(hidden) > 4 else '')) - out.append(f"graph on `{n}`: " + ' | '.join(parts)) - return out - - -_LINES = {} -def linecache_line(f, ln): - if f not in _LINES: - try: - with open(os.path.join(cwd, f), encoding='utf-8', errors='replace') as h: _LINES[f] = h.read().split('\n') - except OSError: _LINES[f] = None - L = _LINES[f] - return L[ln - 1] if L and 0 < ln <= len(L) else None - - -def reach_counts(mid): - try: - tot = q("SELECT count(*) n FROM symbols WHERE is_test = 1 AND method_id IS NOT NULL")[0]['n'] - if not tot: return '' - rows = q("""WITH RECURSIVE r(id,d) AS ( - SELECT ?, 0 - UNION SELECT ce.caller_id, r.d+1 FROM call_edges ce JOIN r ON ce.callee_method_id=r.id WHERE r.d k else '') - lines.append(head) - if con and d['kind'] in ('signature', 'field', 'type', 'removed'): lines.append(f" must change with it ({len(con)}): " + ', '.join(f"{x['display']} ({x['why']})" for x in con[:4]) + (' …' if len(con) > 4 else '')) - if prod: lines.append(f" produces / writes it ({len(prod)}): " + names(prod)) - # THE FAST PATH COUNTS LESS THAN IT SOUNDS LIKE. graph_sql answers from call_edges: resolved callers, - # and the by-name sites it can see. The rules add the [in scope], [text] and reference layers, which on - # a field or a wide method is most of the answer — measured on the JVM parser, 1 against 93 for a field and 5 - # against 137 for a tokeniser method. A COUNT is a claim about completeness, so the fast path does not - # make one: it names what it has and says where the rest is. - 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'] or d.get('shown_target')}` adds the by-name, in-scope and text layers)") - # `reached` is a LIST OF PLACEHOLDERS from the SQL shim (graph_sql.impact_shaped fills it with None, - # deliberately, because both hooks only take len() of it — resolving a location for rows nobody prints cost - # 5.7 s against 1.7 s on a wide target). Iterating it and calling .get() therefore raised AttributeError and - # killed this hook, so the PostToolUse block — the blast radius of an edit that just landed, the plugin's - # most-used output — was never emitted on any graph the shim answers for, silently, because a hook's stderr - # goes nowhere. The line was dead code: `ent` was never read. - # 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 changed declaration(s): impact() with no name (`axiomcode impact`) answers for every edit") - if decls: - for n in ch.get('notes', [])[:2]: lines.append(f" added: {n}") - 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:]], cwd)) -elif tool == 'Read': - fp = _where._abs(inp.get('file_path', ''), scwd); rel = rel_of(fp) - a = int(inp.get('offset') or 1); b = a + int(inp.get('limit') or 100000) - # a member the language synthesises (an enum's values() / valueOf(), a default constructor) is not declared on any line: not listed as one - rows = q("SELECT s.id, s.method_id, s.display, s.line, s.end_line FROM symbols s JOIN methods m ON m.id = s.method_id WHERE (s.file = ? OR s.file LIKE ?) AND s.method_id IS NOT NULL AND s.kind <> 'module' AND m.kind NOT IN ('ENUM_VALUES', 'ENUM_VALUE_OF', 'DEFAULT_CONSTRUCTOR') AND s.line <= ? AND s.end_line >= ? ORDER BY s.line", rel, '%/' + rel.lstrip('/'), b, a) - # the graph describes the tree it was indexed from: a file edited since has moved lines and maybe other declarations. - # That tree is the recorded indexed-tree, uncommitted edits included, and after a background refresh (#1305) it holds - # edits the commit does not: compared against the commit, a file the graph is current for read as stale. - stale = '' - try: - built = open(os.path.join(cwd, '.axiomcode', 'out', 'stamp')).read().split('-')[0] - try: against = open(os.path.join(cwd, '.axiomcode', 'out', 'indexed-tree')).read().strip() or built - except OSError: against = built - if against != 'nogit' and subprocess.run(['git', 'diff', '--quiet', against, '--', rel], cwd=cwd, capture_output=True).returncode == 1: stale = f" — this file changed since the graph was built at {built[:10]}: lines are the graph's, not the file's" - elif against == 'nogit': # no commit recorded: the hash the index read it with - import ax_fresh - rec = ((ax_fresh.load_table(cwd) or {}).get('files') or {}).get(rel) - if rec and ax_fresh.digest(os.path.join(cwd, rel)) != rec[2]: stale = " — this file changed since the graph was indexed: lines are the graph's, not the file's" - except Exception: pass - if rows: - st = load_state(); ctx = set(context_ids()) - done = set(st.get('annotated_ids', [])) # declarations a block already annotated - opened = set(st.get('opened', [])) | {rel} - st['opened'] = sorted(opened) - st['reads'] = ([{'file': rel, 'ids': [r['id'] for r in rows[:12]], 'names': [r['display'] for r in rows[:12]]}] + st['reads'])[:6]; save_state(st) - # the model has the text it read; the block carries only what that text cannot show: an edge whose other end is in - # another file, or in this file but OUTSIDE the range read (a whole-file read shows every intra-file call already); - # overrides (a same-file inner / enum class overriding is not visible as a call either); unresolved sites. - # Counts by default, names only where they carry information (1–3 callers, an edge to what was read before); - # ranked ★ (connected to earlier Reads) first, then few-caller methods, then the rest as one line; 6 lines at most - lo, hi = rows[0]['line'], rows[-1]['end_line'] or b # the lines the text actually covers - mids = [r['method_id'] for r in rows]; ids = [r['id'] for r in rows]; ph = ','.join('?' * len(rows)) - # the other end is in the text the model just read: the range READ, which runs past the last declaration (a script's - # top-level code after its last function is text the model has), not the span of the declarations in it - def visible(f, ln): return f == rel and min(a, lo) <= ln <= max(b - 1, hi) - # A CALLER IS SHOWN BY THE CALL, NOT BY ITS DECLARATION. Judged by the caller's own line, a file's top level (its - # ``, declared at L1) was never in a range that did not start at L1, so reading a script's functions got - # `main L56 ← trend. L1` for every one of them — the calls at the bottom of the very text just read. In the - # loop's runs that shape was most of the Read blocks nobody acted on. A caller whose call site lies in the range is - # visible; the caller's line is the fallback where a graph records no site line. - up = collections.defaultdict(list); anyup = set() # anyup: has a caller at all, shown or not - sites = collections.defaultdict(list) - for e in q(f"SELECT e.callee_method_id m, cr.id, cr.display d, cr.file f, cr.line ln, cr.kind k, cs.start_line sl FROM call_edges e JOIN symbols cr ON cr.id = e.caller_id LEFT JOIN call_sites cs ON cs.id = e.call_site_id WHERE e.callee_method_id IN ({ph}) ORDER BY cr.is_test, cr.display", *mids): - sites[(e['m'], e['id'])].append(e) - for (m, _), es in sites.items(): - anyup.add(m); e = dict(es[0]) - if any(visible(x['f'], x['sl'] or x['ln']) for x in es): continue - if e['k'] == 'module': e['ln'] = min((x['sl'] for x in es if x['sl']), default=e['ln']) # a top level is where its call is - if e['d'] not in {x['d'] for x in up[m]}: up[m].append(e) # overloads of one caller are one name - # A CALLER THROUGH AN INTERFACE OR A BASE METHOD IS A CALLER (#1542): impact lists it, and call_edges alone does - # not hold it where the engine narrowed an interface-typed field to its one bean. Counted from the same reader. - via = _graphline.callers_via_base(con, mids, cwd) - if via: - vids = sorted({c for cs in via.values() for c in cs}) - vrow = {r['id']: r for r in q(f"SELECT id, display d, file f, line ln, kind k FROM symbols WHERE id IN ({','.join('?' * len(vids))})", *vids)} - for m, cs in via.items(): - anyup.add(m) - known = {e['id'] for (m_, _), es in sites.items() if m_ == m for e in es} # already a call_edges caller - for c in sorted(cs, key=lambda c: (vrow[c]['d'] if c in vrow else c)): - if c in vrow and c not in known and not visible(vrow[c]['f'], vrow[c]['ln']) and vrow[c]['d'] not in {x['d'] for x in up[m]}: - up[m].append(dict(vrow[c], sl=None)) - dn = collections.defaultdict(list) - for e in q(f"SELECT DISTINCT e.caller_id c, ce.id, ce.display d, ce.file f, ce.line ln FROM call_edges e JOIN symbols ce ON ce.method_id = e.callee_method_id WHERE e.caller_id IN ({ph}) AND e.callee_provenance = 'client'", *ids): - if not visible(e['f'], e['ln']) and e['d'] not in {x['d'] for x in dn[e['c']]}: dn[e['c']].append(e) - ov_in, ov_out = collections.Counter(), collections.Counter() - if q("SELECT 1 FROM sqlite_master WHERE name='dispatch_candidates'"): - # same-owner candidates are overloads, not overrides — never reported as dispatch - for e in q(f"SELECT DISTINCT dc.base_method_id b, s.file f, s.owner o FROM dispatch_candidates dc JOIN symbols s ON s.method_id = dc.candidate_method_id JOIN symbols bs ON bs.method_id = dc.base_method_id WHERE dc.base_method_id IN ({ph}) AND dc.candidate_method_id <> dc.base_method_id AND s.owner <> bs.owner", *mids): - (ov_in if e['f'] == rel else ov_out)[e['b']] += 1 - unres = collections.Counter() - if q("SELECT 1 FROM sqlite_master WHERE name='unresolved_sites'"): - for e in q(f"SELECT caller_id c, count(*) n FROM unresolved_sites WHERE caller_id IN ({ph}) GROUP BY caller_id", *ids): unres[e['c']] = e['n'] - info = [] - for r in rows: - # a type-level synthetic has no body to call into, so naming it here points the reader at - # nothing and cannot be checked — its line does not carry its name (ax_contract.SYNTHETIC) - # `display`, because that is the column this query selects AND the string that gets printed — - # guarding on a column the row does not carry reads as '' and silently never fires - if _ax.is_synthetic(r['display']): continue - u, d = up[r['method_id']], dn[r['id']] - su, sd = [x for x in u if x['id'] in ctx], [x for x in d if x['id'] in ctx] - if u or d or ov_in[r['method_id']] or ov_out[r['method_id']] or unres[r['id']]: info.append(dict(r=r, up=u, dn=d, ovi=ov_in[r['method_id']], ovo=ov_out[r['method_id']], un=unres[r['id']], su=su, sd=sd, star=su + sd)) - # A DECLARATION IS ANNOTATED ONCE A SESSION, however its lines are reached again: the same range, an overlapping - # one, the whole file after a range of it, or the same file through another spelling of its path. A key on the - # read's own arguments caught only the first of those. - info = [x for x in info if x['r']['id'] not in done] - # A BLOCK OF BARE UNRESOLVED COUNTS NAMES NOTHING TO GO TO (`main L44 ?7 unresolved call(s)` and no edge): no caller, - # callee or override to open, so it is context spent on a number. Kept when one declaration carries anything else. - if not any(x['up'] or x['dn'] or x['ovi'] or x['ovo'] or task_hits(x['r']['display']) for x in info): info = [] - block_ids = [x['r']['id'] for x in info] - # an edge into a file the agent has not opened is what a read cannot show it; one into a file it has read is - # something it may already have seen from the other end - novel = any(y['f'] not in opened for x in info for y in x['up'] + x['dn']) or any(x['ovo'] for x in info) - short = lambda d: d.split('.')[-1] if d.count('.') > 1 else d - def nm(y): return y['d'] + (f" L{y['ln']}" if y['f'] == rel else '') # a same-file end outside the range: say where - # a caller count of 0 says WHY where the graph knows (#1507 cluster): `←entry (http)`, `←0 resolved, 2 by name`, - # `←? framework (@Scheduled)`. A framework-called method printed as `←0` read as dead code - def ups(x): - if x['up']: return str(len(x['up'])) - # every caller is in the lines just read: `←0` there read as "nothing calls it" while a Grep of the same name - # said `← 1`; the count is of callers the text does not show, and here there are none to add - if x['r']['method_id'] in anyup: return ' callers in range' - if 'zl' not in x: x['zl'] = _graphline.zero_label(con, x['r']['method_id']) - return x['zl'] - def line(x): - r = x['r']; parts = [] - if x['su']: parts.append("← " + ', '.join(f"{nm(y)} ★" for y in x['su'][:2]) + (f", +{len(x['up']) - min(2, len(x['su']))}" if len(x['up']) > min(2, len(x['su'])) else '')) - elif 1 <= len(x['up']) <= 3: parts.append("← " + ', '.join(nm(y) for y in x['up'])) - elif x['up']: parts.append(f"←{len(x['up'])}") - elif x['r']['method_id'] not in anyup and ups(x) != '0': parts.append(f"←{ups(x)}") - if x['ovi'] or x['ovo']: parts.append("→ dispatch: " + ', '.join(filter(None, [f"{x['ovi']} override(s) in this file" if x['ovi'] else '', f"{x['ovo']} elsewhere" if x['ovo'] else '']))) - if x['sd']: parts.append(f"→ {', '.join(f'{nm(y)} ★' for y in x['sd'][:2])}" + (f", +{len(x['dn']) - min(2, len(x['sd']))}" if len(x['dn']) > min(2, len(x['sd'])) else '')) - elif x['dn']: parts.append(f"→{len(x['dn'])}" + (" " + ', '.join(nm(y) for y in x['dn'][:2]) if len(x['dn']) <= 2 else '')) - if x['un']: parts.append(f"?{x['un']} unresolved call(s)") - if x.get('hits'): parts.append("— your task says " + ', '.join(repr(h) for h in x['hits'])) - return f" {short(r['display'])} L{r['line']} " + ' '.join(parts) - # ★ lines ranked by how many DISTINCT earlier-read methods reach them; one hub caller cannot claim every slot - for x in info: x['hits'] = task_hits(x['r']['display']) - # a declaration whose NAME carries the task's words is shown first, ahead of the better-connected ones - stars = sorted([x for x in info if x['star'] or x['hits']], - key=lambda x: (-len(x['hits']), -len({y['id'] for y in x['star']}))) - seen = collections.Counter(); picked = [] - for x in stars: - k = tuple(sorted({y['id'] for y in x['star']})) - if seen[k] < 2: picked.append(x); seen[k] += 1 - few = [x for x in info if x not in picked and (1 <= len(x['up']) <= 3 or x['ovi'] or x['ovo'])] - if not info: rows = [] # everything here was said before, or nothing here has an edge: no header over no lines - if rows: lines.append(f"graph: {os.path.basename(rel)}:{lo}-{hi} — {len(rows)} callable(s); edges the text does not show (cross-file, outside the range, overrides, unresolved)" + (" ★ = what you read before" if picked else '') + ":" + stale) - shown = (picked + few)[:5] if rows else [] - for x in shown: lines.append(line(x)) - left = [x for x in info if x not in shown] if rows else [] - # an anonymous function (``, ``) has no name to grep or ask about: counted in `+N more`, not listed - anon = lambda x: re.fullmatch(r'<[\w-]+>', x['r']['display'].rsplit('.', 1)[-1]) is not None # `Walker.` too - n_left = len(left); left = [x for x in left if not anon(x)] - if left: lines.append(" " + ('+%d more: ' % n_left) + ', '.join(f"{short(x['r']['display'])} ←{ups(x)}" + (f" →{len(x['dn'])}" if x['dn'] and not x['up'] else '') + (f" ?{x['un']}" if x['un'] else '') for x in sorted(left, key=lambda x: (-len(x.get('hits') or ()), -(len(x['up']) + x['un'])))[:6]) + (' …' if len(left) > 6 else '') + " (grep Type.name or axiomcode path to narrow)") -elif tool == 'Grep' and not (inp.get('path') and os.path.relpath(os.path.realpath(_where._abs(inp['path'], scwd)), os.path.realpath(cwd)).split(os.sep)[0] == '..'): - # (a Grep of a path outside this tree is about another codebase — nothing here to add) - # a real search is rarely one identifier: `hasNext\(\)|\.next\(\)|close\(\)`, `getScanner|RTBoundValidator|withSSTablesIterated`. - # Split the alternation, strip the regex around each branch, keep the identifiers, look each one up — in parallel, one - # connection per thread — and cap the whole block so a 6-way grep still reads as a glance - import concurrent.futures - pat = str(inp.get('pattern', '')) - idents = [] - # `a|b` is alternation for rg and grep -E, `a\|b` for plain grep: both are branches (a literal pipe is not an identifier) - for br in re.split(r'\\\||(? onceAWeekTrigger). It is looked up exactly, never by prefix, - # and kept below only when it connects to what the agent read this session — the `close` it was just reading. - plain = n.islower() and '_' not in n - # A CONSTANT-SHAPED WORD IS NOT A CALLABLE'S NAME (`FAIL`, `DONE`, `PARSER`, `CACHE`), and the prefix match that - # used to follow was LIKE, which is case-blind: `FAIL` found `fail`, `PARSER` found `parserPresent`. The prefix is - # now case-sensitive (GLOB, which the name index also serves), and a grep run through the shell is matched as - # whole names only: its pattern is as often a word in a log as a name in code (#1604). - if re.fullmatch(r'[A-Z0-9_]+', n): return n, [], [] - COLS = "id, method_id, display, file, line, is_test" - rows = c.execute(f"SELECT {COLS} FROM symbols WHERE name = ? AND method_id IS NOT NULL AND kind <> 'module' ORDER BY file LIMIT 40", (n,)).fetchall() \ - or ([] if plain or _from_shell else c.execute(f"SELECT {COLS} FROM symbols WHERE name GLOB ? AND method_id IS NOT NULL AND kind <> 'module' ORDER BY length(name), file LIMIT 12", (n + '*',)).fetchall()) - # A NAME DECLARED SEVERAL TIMES IS A BASE AND ITS OVERRIDES (#1546): the base first, with how many override it, then - # production before tests, then by path. Ordered by path alone, two test stubs under `adapter-*` took both slots - # and the base, the main-source override and the count were all left out. - nover = {} - if len(rows) > 1: - has_ovr = c.execute("SELECT 1 FROM overrides LIMIT 1").fetchone() is not None if c.execute("SELECT 1 FROM sqlite_master WHERE name='overrides'").fetchone() else False - has_dc = c.execute("SELECT 1 FROM sqlite_master WHERE name='dispatch_candidates'").fetchone() is not None - for r in rows: - k = c.execute("SELECT count(DISTINCT overriding_method_id) FROM overrides WHERE method_id = ?", (r['method_id'],)).fetchone()[0] if has_ovr else 0 - if not k and not has_ovr and has_dc: # Python and TypeScript keep the envelope in dispatch_candidates - k = c.execute("SELECT count(DISTINCT candidate_method_id) FROM dispatch_candidates WHERE base_method_id = ? AND candidate_method_id <> base_method_id", (r['method_id'],)).fetchone()[0] - nover[r['id']] = k - rows = sorted(rows, key=lambda r: (-nover[r['id']], r['is_test'] or 0, r['file'])) - # the declarations connected to what the agent just read: called BY a read callable, or CALLING one — first, and marked - rel_ = {} - unres = [] - if ctx: - ph = ','.join('?' * len(ctx)) - # a call written `n` inside what was read whose receiver the engine could not type: it may be any of these — say so - unres = c.execute(f"SELECT cs.caller_id, cs.start_line FROM call_sites cs JOIN unresolved_sites u ON u.call_site_id = cs.id WHERE cs.callee_name = ? AND cs.caller_id IN ({ph}) LIMIT 3", (n, *ctx)).fetchall() - if ctx and (len(rows) > 1 or plain): - for r in rows: - e = c.execute(f"SELECT cr.id AS who, 'called from' AS how FROM call_edges e JOIN symbols cr ON cr.id = e.caller_id WHERE e.callee_method_id = ? AND e.caller_id IN ({ph}) LIMIT 1", (r['method_id'], *ctx)).fetchone() \ - or c.execute(f"SELECT ce.id AS who, 'calls' AS how FROM call_edges e JOIN symbols ce ON ce.method_id = e.callee_method_id WHERE e.caller_id = ? AND ce.id IN ({ph}) LIMIT 1", (r['id'], *ctx)).fetchone() - if e: rel_[r['id']] = (e['how'], ctx_names.get(e['who'], '?')) - rows = sorted(rows, key=lambda r: r['id'] not in rel_) # stable: the order above within each group - if plain and not rel_ and not unres: return n, [], [] - # OVERLOADS IN ONE FILE ARE ONE NAME TO THE READER: `ISender.Send` declared twice in one interface printed as the - # same line twice. Each (display, file) is one line, saying how many declarations it stands for. - grp = {} - for r in rows: grp.setdefault((r['display'], r['file']), []).append(r) - rows = [v[0] for v in grp.values()]; n_ol = {v[0]['id']: len(v) for v in grp.values()} - total = len(rows); rows = rows[:2] - out = [] - paths = _graphline.distinct_paths([r['file'] or '' for r in rows]) - for r, path in zip(rows, paths): - # PRODUCTION CALLERS ARE NAMED BEFORE TESTS (#1507): unordered, SQLite returned them by display, so two test - # methods took both name slots and the three production callers hid behind the count - up = c.execute("SELECT DISTINCT cr.display d, cr.is_test t FROM call_edges e JOIN symbols cr ON cr.id = e.caller_id WHERE e.callee_method_id = ? ORDER BY cr.is_test, cr.display LIMIT 40", (r['method_id'],)).fetchall() - via = '' - # the callers through an interface or base method, which impact lists as `calls it (via the interface)` - vb = sorted(_graphline.callers_via_base(c, [r['method_id']], cwd).get(r['method_id'], ())) - if vb: - have = {x['d'] for x in up} - more = [x for x in c.execute(f"SELECT DISTINCT display d, is_test t FROM symbols WHERE id IN ({','.join('?' * len(vb))})", vb).fetchall() if x['d'] not in have] - if more: - up = sorted(list(up) + more, key=lambda x: (x['t'], x['d']))[:40] - via = f"; {len(more)} through the interface or base" if len(more) < len(up) else '; through the interface or base' - nt = sum(1 for x in up if x['t']) - # by DISPLAY, like the names printed beside it: two overloads of one callee are one name to the reader - dn = c.execute("SELECT count(*) n FROM (SELECT DISTINCT ce.display FROM call_edges e JOIN symbols ce ON ce.method_id = e.callee_method_id WHERE e.caller_id = ? AND e.callee_provenance = 'client')", (r['id'],)).fetchone()['n'] - un = c.execute("SELECT count(*) n FROM unresolved_sites WHERE caller_id = ?", (r['id'],)).fetchone()['n'] - tag = f" ★ {rel_[r['id']][0]} {rel_[r['id']][1]} (which you just read)" if r['id'] in rel_ else '' - # the tests are counted apart only below the 40-row cap: at the cap the split is not known - callers = (f"{len(up)} (" + ', '.join(x['d'].split('.')[-1] for x in up[:2]) + (', …' if len(up) > 2 else '') - + (f"; {nt} in tests" if nt and 2 < len(up) < 40 and nt < len(up) else '') + via + ")") if up \ - else _graphline.zero_label(c, r['method_id'], None, r['is_test']) - out.append(f" {r['display']} {path}:{r['line']} ← {callers} → {dn}" + (f" ? {un}" if un else '') - + (f" ⇣ {nover[r['id']]} override(s)" if nover.get(r['id']) else '') - + (f" ({n_ol[r['id']]} overloads)" if n_ol.get(r['id'], 1) > 1 else '') + tag) - if total > len(rows) and out: out[-1] += f" (+{total - len(rows)} more declaration(s){'' if total < 40 else ' or more'})" - if unres: out.append(f" ({ctx_names.get(unres[0]['caller_id'], '?')}, which you just read, calls a `{n}` at L{unres[0]['start_line']} whose receiver is not typed — it may be any of the above)") - return n, rows, out - if idents and os.environ.get('AXIOMCODE_GREP_AID', '1').lower() not in ('0', 'off', 'false'): - lines += grep_aid(idents, ev.get('tool_response')) - elif idents: - with concurrent.futures.ThreadPoolExecutor(max_workers=min(6, len(idents))) as ex: found = list(ex.map(lookup, idents)) - found = [(n, rows, out) for n, rows, out in found if rows] - opened = set(load_state().get('opened', [])) - novel = any(r['file'] not in opened for _, rows, _ in found for r in rows) - if found: - lines.append(f"graph: {len(found)}/{len(idents)} name(s) are callables (← callers → callees ? unresolved)") - budget = 6 - for n, rows, out in found: - if budget <= 0: lines.append(" …"); break - take = out[:max(1, min(len(out), budget // max(1, len(found) - found.index((n, rows, out)))))] - lines += take; budget -= len(take) -elif tool == 'Glob': - # a file search: the files the graph knows under that name, with what each declares (its callables, most-called first) - toks = [t for t in re.findall(r'[A-Za-z_][\w-]{2,}', str(inp.get('pattern', '')).split('/')[-1]) if t.lower() not in ('java', 'ts', 'tsx', 'js', 'py', 'test', 'src', 'main')] - frag = max(toks, key=len) if toks else '' - if len(frag) >= 3: - rows = q("SELECT file, count(*) n FROM symbols WHERE file LIKE ? AND method_id IS NOT NULL AND kind <> 'module' GROUP BY file ORDER BY n DESC LIMIT 4", f"%{frag}%") - if rows: - lines.append(f"graph: {len(rows)} file(s) matching *{frag}* have callables —") - for r in rows: - top = q("SELECT s.display, (SELECT count(*) FROM call_edges e WHERE e.callee_method_id = s.method_id) c FROM symbols s WHERE s.file = ? AND s.method_id IS NOT NULL AND s.kind <> 'module' ORDER BY c DESC LIMIT 3", r['file']) - lines.append(f" {r['file']}: {r['n']} callable(s); most called: " + ', '.join(f"{t['display']} ({t['c']})" for t in top)) -# A PER-SESSION BUDGET FOR WHAT A READ OR A SEARCH GETS (#1199). Each block is small, but a session reads a lot and -# every block stays in the agent's context for the rest of it: in three runs of an implementation task these blocks -# came to 23k-27k characters a run, more than any graph answer in the same runs. The edges of an EDIT (what the change -# just made reaches) are a different signal and stay outside the budget. -# THE MOST USEFUL BLOCKS GET THE BUDGET. A block whose every edge ends in a file the agent has already opened competes -# for the first half only; the second half is kept for blocks that point somewhere it has not been, which is the one -# thing a read cannot show. -ENRICH_BUDGET = int(os.environ.get('AXIOMCODE_ENRICH_BUDGET', '6000')) -if lines and tool in ('Read', 'Grep', 'Glob'): - st = load_state() - key = f"{tool}|{inp.get('file_path') or inp.get('pattern')}|{inp.get('offset') or ''}|{inp.get('limit') or ''}" - seen = st.setdefault('annotated', []) - spent = st.get('enriched_chars', 0) - if key in seen: - lines = [] # the same range, or the same search, is annotated once - elif spent >= ENRICH_BUDGET: - lines = [] if st.get('budget_said') else [f"graph: this session's enrichment budget ({ENRICH_BUDGET} characters) is spent, so reads and searches get no more of these blocks; ask impact(name) / path(start, end) directly (mcp__plugin_axiomcode_axiomcode__impact / __path; shell `axiomcode impact` / `axiomcode path`) for a declaration's edges"] - st['budget_said'] = True - elif not novel and spent >= ENRICH_BUDGET // 2: - lines = [] # only edges into files already opened: the rest is kept for new ones - else: - st['enriched_chars'] = spent + sum(len(l) + 1 for l in lines); seen.append(key) - st['annotated_ids'] = st.get('annotated_ids', []) + block_ids - save_state(st) -# every invocation is logged next to the graph — stream-json does not carry additionalContext, so this is how a run proves the -# hook fired and what it added -try: - with open(os.path.join(cwd, '.axiomcode', 'hooks.jsonl'), 'a') as f: f.write(json.dumps({'tool': ev.get('tool_name'), 'as': 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', 'offset', 'limit', 'pattern', 'old_string', 'new_string')}, 'text': '\n'.join(lines)}) + '\n') -except OSError: pass -_host.emit('PostToolUse', '\n'.join(lines)) diff --git a/plugins/axiomcode/hooks/hooks.json b/plugins/axiomcode/hooks/hooks.json index e7a5ffbc..a97dd87f 100644 --- a/plugins/axiomcode/hooks/hooks.json +++ b/plugins/axiomcode/hooks/hooks.json @@ -1,16 +1,6 @@ { "hooks": { "PostToolUse": [ - { - "matcher": "Read|Grep|Glob|Bash|Edit|Write|MultiEdit|mcp__plugin_axiomcode_axiomcode__.*", - "hooks": [ - { - "type": "command", - "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/run.js\" enrich.py", - "timeout": 20 - } - ] - }, { "matcher": "Bash", "hooks": [ @@ -33,16 +23,6 @@ } ], "PreToolUse": [ - { - "matcher": "Read|Grep|Glob|Bash|mcp__plugin_axiomcode_axiomcode__.*", - "hooks": [ - { - "type": "command", - "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/run.js\" direct.py", - "timeout": 10 - } - ] - }, { "matcher": "Edit|Write|MultiEdit", "hooks": [ diff --git a/plugins/axiomcode/hooks/orient.py b/plugins/axiomcode/hooks/orient.py index c355b850..338c4bf7 100755 --- a/plugins/axiomcode/hooks/orient.py +++ b/plugins/axiomcode/hooks/orient.py @@ -3,9 +3,8 @@ The active verbs only help an agent that calls them, and the one measurement of this skill in an agent's hands recorded no graph queries at all in six of six runs — the gap was never the answer, it was that -nobody asked the question. The passive half already enriches a Read or a grep AFTER the agent has chosen -where to look; this fires BEFORE, on the task itself, which is the only moment where orientation changes -which file gets opened first. +nobody asked the question. This fires on the task itself, before the agent has chosen where to look, which +is the only moment where orientation changes which file gets opened first. Rules it holds itself to: · ONCE per session. Orientation is a first-turn need; repeating it on every prompt is noise that costs diff --git a/plugins/axiomcode/hooks/validate.py b/plugins/axiomcode/hooks/validate.py index fe3d7ba0..3a5e70de 100644 --- a/plugins/axiomcode/hooks/validate.py +++ b/plugins/axiomcode/hooks/validate.py @@ -11,8 +11,9 @@ Edit (PreToolUse / PostToolUse / Bash / UserPromptSubmit blocks) each declaration named spans a line the edit changed (from `axiomcode changed` on the same texts); every name under must-change / produces / reads is in `axiomcode impact`'s answer for that target with that role; the counts match -Events are generated on the repo (Reads of whole files and ranges, Greps of declared identifiers, edits that change a body, -a signature, a field's type) or replayed from .axiomcode/hooks.jsonl (--replay: entries that recorded their input and text). +Events are generated on the repo (edits that change a body, a signature, a field's type) or replayed from +.axiomcode/hooks.jsonl (--replay: entries that recorded their input and text). No hook annotates a Read or a Grep any +more, so the Read and Grep checks apply to replayed logs only. Prints facts checked / facts wrong, and every wrong fact.""" import collections, atexit, json, os, random, re, sqlite3, subprocess, sys, tempfile, shutil, time sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), @@ -247,17 +248,7 @@ def main(argv): rel = os.path.relpath(os.path.realpath(inp['file_path']), V_.repo).replace(os.sep, '/'); a0 = int(inp.get('offset') or 1); V_.check_read(e['text'], rel, a0, a0 + int(inp.get('limit') or 100000)) elif e.get('as') == 'Grep' and inp.get('pattern'): V_.check_grep(e['text'], re.sub(r'\W.*', '', inp['pattern'])) else: - files = [r[0] for r in V_.q("SELECT DISTINCT file FROM symbols WHERE method_id IS NOT NULL AND kind <> 'module' AND is_test = 0")] - rnd.shuffle(files) - for rel in files[:n_reads]: - fp = os.path.join(V_.repo, rel) - if not os.path.exists(fp): continue - V_.check_read(hook('enrich.py', 'PostToolUse', 'Read', {'file_path': fp}, V_.repo), rel, 1, 100000) - n = len(open(fp, errors='replace').read().split('\n')); a0 = rnd.randint(1, max(1, n - 40)); lim = rnd.choice([20, 60, 120]) - V_.check_read(hook('enrich.py', 'PostToolUse', 'Read', {'file_path': fp, 'offset': a0, 'limit': lim}, V_.repo), rel, a0, a0 + lim) - names = [r[0] for r in V_.q("SELECT DISTINCT name FROM symbols WHERE method_id IS NOT NULL AND kind = 'method' AND length(name) > 4")] - for nm in rnd.sample(names, min(n_greps, len(names))): V_.check_grep(hook('enrich.py', 'PostToolUse', 'Grep', {'pattern': nm}, V_.repo), nm) - # edits: a body line, a signature (a parameter added), a field's type — applied to a copy of the file, PreToolUse and PostToolUse both + # edits: a body line, a signature (a parameter added), a field's type — applied to a copy of the file (PreToolUse) meths = [dict(r) for r in V_.q("SELECT * FROM symbols WHERE method_id IS NOT NULL AND kind = 'method' AND is_test = 0 AND end_line - line >= 3")] fields = [dict(r) for r in V_.q("SELECT * FROM symbols WHERE method_id IS NULL AND type_id IS NULL AND kind IN ('field') AND is_test = 0 AND line > 0")] rnd.shuffle(meths); rnd.shuffle(fields); done = 0 @@ -299,18 +290,6 @@ def main(argv): if kind == 'signature': V_.fact(bool(blk), f"PreToolUse: no block for a signature edit of {s['display']} ({where})") else: V_.fact(not blk, f"PreToolUse: a block for a body-only edit of {s['display']} ({where})") if blk: V_.check_change(blk, s['file'], '\n'.join(L), new_text) - # after the edit lands: write the copy in place, run the PostToolUse block, restore - # RESTORE FROM MEMORY, NOT FROM A .bak ON DISK. Two runs of this harness on one repository interleaved - # their copy/move pairs, one restore consumed the other's backup, and the last writer left `# __edited` - # sitting in the subject's source — where the next measurement would have taken it for the code. A - # harness that edits a repository in place must be able to put it back without depending on a file. - original = '\n'.join(L) - open(fp, 'w').write(new_text) - try: - blk2 = hook('enrich.py', 'PostToolUse', 'Edit', {'file_path': fp, 'old_string': old_s, 'new_string': new_s}, V_.repo, session=f'w{done}') - V_.fact(bool(blk2), f"PostToolUse: no block for a {kind} edit of {s['display']} ({s['file']}:{i + 1 if kind == 'body' else s['line']}: {old_s.strip()[:60]!r})") - if blk2: V_.check_change(blk2, s['file'], '\n'.join(L), new_text) - finally: open(fp, 'w').write(original) done += 1 for f_ in fields[:max(2, n_edits // 3)]: fp = os.path.join(V_.repo, f_['file']) diff --git a/plugins/axiomcode/rules/axiomcode.mdc b/plugins/axiomcode/rules/axiomcode.mdc index 129677f3..0432af2d 100644 --- a/plugins/axiomcode/rules/axiomcode.mdc +++ b/plugins/axiomcode/rules/axiomcode.mdc @@ -5,9 +5,9 @@ alwaysApply: true # axiomcode -Search with grep and Read as usual: after a grep, the call graph adds only what grep cannot know (which -declaration each match reaches, the callers that never spell the name). Ask it directly, through the axiomcode -MCP tools, for what no text search answers: +Search with grep and Read as usual; the call graph stays out of the way until you ask it. Ask it directly, +through the axiomcode MCP tools, for what no text search answers — which declaration a call reaches, the callers +that never spell the name: impact(name) who calls it, what a change to it reaches, and its tests; impact() with no name: the same for your uncommitted edits diff --git a/plugins/axiomcode/skills/axiomcode/SKILL.md b/plugins/axiomcode/skills/axiomcode/SKILL.md index 814a544f..8b9bc79b 100644 --- a/plugins/axiomcode/skills/axiomcode/SKILL.md +++ b/plugins/axiomcode/skills/axiomcode/SKILL.md @@ -1,7 +1,7 @@ --- name: axiomcode description: >- - Use for any why, what or where question about code — how a codebase works, where something lives, who calls it, what a change to it breaks, which tests cover an edit. Also use when resolving an issue or bug report, which names a symptom rather than a file. Examples: "How does X work?", "Where do I change Y?", "What calls this?", "What breaks if I change Z?", "Which tests do I run?", "Fix this issue". Search with grep as usual: after a grep the graph adds only what grep cannot know — which declaration each match reaches and the callers that never spell the name. Answers come from a resolved call graph, so they include callers that never spell the name — through an interface, an override, a callback, dependency injection or a config key — and every place comes with the code of the function it sits in. Call the MCP tools directly, no need to load this skill first: impact(name) for who calls it and what a change reaches (with no name: your uncommitted edits), path(start, end) for how A reaches B, tests() for the tests your edits reach, context(task, source=True) for how something works as a step-by-step call flow with each step's code. Only when those tools are not in your list, the same from the shell: `axiomcode impact `, `axiomcode path `, `axiomcode tests`, `axiomcode context "" --source`. Java, TypeScript, Python, JavaScript, C#. + Use for any why, what or where question about code — how a codebase works, where something lives, who calls it, what a change to it breaks, which tests cover an edit. Also use when resolving an issue or bug report, which names a symptom rather than a file. Examples: "How does X work?", "Where do I change Y?", "What calls this?", "What breaks if I change Z?", "Which tests do I run?", "Fix this issue". Search with grep as usual; ask the graph for what grep cannot know — which declaration a call reaches and the callers that never spell the name. Answers come from a resolved call graph, so they include callers that never spell the name — through an interface, an override, a callback, dependency injection or a config key — and every place comes with the code of the function it sits in. Call the MCP tools directly, no need to load this skill first: impact(name) for who calls it and what a change reaches (with no name: your uncommitted edits), path(start, end) for how A reaches B, tests() for the tests your edits reach, context(task, source=True) for how something works as a step-by-step call flow with each step's code. Only when those tools are not in your list, the same from the shell: `axiomcode impact `, `axiomcode path `, `axiomcode tests`, `axiomcode context "" --source`. Java, TypeScript, Python, JavaScript, C#. --- # axiomcode diff --git a/skills/axiomcode/SKILL.md b/skills/axiomcode/SKILL.md index 38950cb1..852dd585 100644 --- a/skills/axiomcode/SKILL.md +++ b/skills/axiomcode/SKILL.md @@ -1,7 +1,7 @@ --- name: axiomcode description: >- - Use for any why, what or where question about code — how a codebase works, where something lives, who calls it, what a change to it breaks, which tests cover an edit. Also use when resolving an issue or bug report, which names a symptom rather than a file. Examples: "How does X work?", "Where do I change Y?", "What calls this?", "What breaks if I change Z?", "Which tests do I run?", "Fix this issue". Search with grep as usual: after a grep the graph adds only what grep cannot know — which declaration each match reaches and the callers that never spell the name. Answers come from a resolved call graph, so they include callers that never spell the name — through an interface, an override, a callback, dependency injection or a config key — and every place comes with the code of the function it sits in. Call the MCP tools directly, no need to load this skill first: impact(name) for who calls it and what a change reaches (with no name: your uncommitted edits), path(start, end) for how A reaches B, tests() for the tests your edits reach. Only when those tools are not in your list, the same from the shell: `axiomcode impact `, `axiomcode path `, `axiomcode tests`. Java, TypeScript, Python, JavaScript, C#. + Use for any why, what or where question about code — how a codebase works, where something lives, who calls it, what a change to it breaks, which tests cover an edit. Also use when resolving an issue or bug report, which names a symptom rather than a file. Examples: "How does X work?", "Where do I change Y?", "What calls this?", "What breaks if I change Z?", "Which tests do I run?", "Fix this issue". Search with grep as usual; ask the graph for what grep cannot know — which declaration a call reaches and the callers that never spell the name. Answers come from a resolved call graph, so they include callers that never spell the name — through an interface, an override, a callback, dependency injection or a config key — and every place comes with the code of the function it sits in. Call the MCP tools directly, no need to load this skill first: impact(name) for who calls it and what a change reaches (with no name: your uncommitted edits), path(start, end) for how A reaches B, tests() for the tests your edits reach. Only when those tools are not in your list, the same from the shell: `axiomcode impact `, `axiomcode path `, `axiomcode tests`. Java, TypeScript, Python, JavaScript, C#. --- # axiomcode diff --git a/tests/README.md b/tests/README.md index 7c41425e..93114507 100644 --- a/tests/README.md +++ b/tests/README.md @@ -15,15 +15,14 @@ One check needs no graph and is its own script: python3 tests/fastpath.py the hooks' SQL fast path agrees with the rules, shape by shape, on a small Python case by default; --lang java|csharp|typescript for the others (typescript needs the TypeScript engine; indexes, so it needs the engine) - python3 tests/directive.py the PreToolUse directive hook keeps its promises (never blocks, - never raises, silent without a graph, once per session across - repositories, and names the verb for a declared name it is searched for) - python3 tests/hook_languages.py the edit hooks speak for C# as for Java and Python, from one extension table, - and a body edit's command runs the classes that extend an abstract test base + python3 tests/hook_languages.py the edit hook and the tests verb speak for C# as for Java and Python, from one + extension table, and the tests command runs the classes that extend an abstract + test base (indexes a small C# project, so it needs the engine) - python3 tests/hook_rebase.py after a rebase, a pull or a checkout, an edit report names only that edit and says - the base moved once; `changed` reads against the new HEAD; `--range` from a branch - left behind its remote reads from the remote's fork (indexes a small project) + python3 tests/hook_rebase.py after a rebase, `changed` lists only the local edits and says the base moved; a + signature edit is reported before it lands with nothing upstream did; `--range` + from a branch left behind its remote reads from the remote's fork (indexes a small + project) python3 tests/refresh.py the graph refreshes itself after an edit in every language: a query sees the edit, `changed` answers the same before and after, a burst costs one rebuild and queries during it answer (#1305; builds real graphs, needs the engine) @@ -57,14 +56,6 @@ One check needs no graph and is its own script: without the server (#1425; indexes one case, so it needs the engine) python3 tests/hosts.py each hook tells Cursor and Gemini CLI what it tells the original host, in their own event names and output shape (indexes one case, so it needs the engine) - python3 tests/enrich_budget.py what a Read or a Grep adds to a session is capped: a declaration annotated once, - the budget said spent once, its second half kept for edges into unopened files - (#1199; indexes a small project, so it needs the engine) - python3 tests/enrich_lines.py what one enrichment line says: a caller count of 0 says why (entry point, by-name - sites, a framework annotation), production callers before tests, a base before its - overrides, no annotation for a shell grep over output or logs, nothing for an edit - that changes no declaration, one line of tests for a body edit (#1507, #1546, #1604; - indexes a small project, so it needs the engine) python3 tests/engine_choice.py axiomcode-build picks a built engine over an unbuilt clone it sits in, finds the engine the last build used before PATH (the refresh runs from a hook, with the hook's PATH), follows a Windows npm shim, and names every place it looked when diff --git a/tests/directive.py b/tests/directive.py deleted file mode 100644 index 76576227..00000000 --- a/tests/directive.py +++ /dev/null @@ -1,182 +0,0 @@ -#!/usr/bin/env python3 -"""tests/directive.py — the PreToolUse directive hook, checked on the promises it makes. - -This hook runs before EVERY Read, Grep, Glob and Bash in any repository that has a graph. That reach is -the reason it is worth having and the reason it is worth pinning: a hook that raises, blocks, or speaks -when it has nothing to say costs every agent using the plugin something, on every turn. - -Each check names the promise rather than the code path, so a failure here says which promise broke. -""" -import json, os, sqlite3, subprocess, sys, tempfile - -HOOK = os.path.join(os.path.dirname(os.path.abspath(__file__)), - '..', 'plugins', 'axiomcode', 'hooks', 'direct.py') -TMP = tempfile.mkdtemp() # where the per-session stamps go: a clean one per run - - -def fire(repo, tool, inp, session='s1'): - ev = json.dumps({'tool_name': tool, 'tool_input': inp, 'cwd': repo, 'session_id': session}) - r = subprocess.run([sys.executable, HOOK], input=ev, capture_output=True, text=True, timeout=20, - env={**os.environ, 'TMPDIR': TMP}) - return r.returncode, r.stdout.strip(), r.stderr.strip() - - -def ctx(out): - if not out: - return None - return json.loads(out)['hookSpecificOutput']['additionalContext'] - - -def graph(repo, rows): - """a graph.sqlite with just the symbols table the hook reads: (name, display, kind, file, line)""" - os.makedirs(os.path.join(repo, '.axiomcode', 'out'), exist_ok=True) - con = sqlite3.connect(os.path.join(repo, '.axiomcode', 'out', 'graph.sqlite')) - con.execute("CREATE TABLE symbols(id TEXT, name TEXT, display TEXT, kind TEXT, file TEXT, line INT, is_test INT, method_id TEXT)") - con.executemany("INSERT INTO symbols VALUES (?, ?, ?, ?, ?, ?, 0, ?)", - [(d, n, d, k, f, l, d if k in ('method', 'function') else None) for n, d, k, f, l in rows]) - con.commit(); con.close() - - -fails, checked = [], [] -def check(why, cond, detail=''): - checked.append(why) - print(('ok ' if cond else 'FAIL ') + why + (f'\n {detail}' if not cond and detail else '')) - if not cond: - fails.append(why) - - -ROWS = [('doWork', 'Worker.doWork', 'method', 'src/Worker.java', 7), ('Worker', 'Worker', 'class', 'src/Worker.java', 3), - ('work', 'work', 'function', 'src/util.py', 1), ('maxRetries', 'Worker.maxRetries', 'field', 'src/Worker.java', 4), - ('render', 'render', 'function', 'src/a.py', 2), ('render', 'View.render', 'method', 'lib/view.py', 5)] + \ - [('handle', f'H{i}.handle', 'method', f'src/H{i}.java', 3) for i in range(8)] - -with tempfile.TemporaryDirectory() as repo, tempfile.TemporaryDirectory() as other: - os.makedirs(os.path.join(repo, 'src')) - os.makedirs(os.path.join(repo, 'lib')) - open(os.path.join(repo, 'src', 'Worker.java'), 'w').write('class Worker { int maxRetries; void doWork(){} }\n') - open(os.path.join(repo, 'NOTES.md'), 'w').write('doWork\n') - - rc, out, err = fire(repo, 'Grep', {'pattern': 'doWork'}) - check('a repository with no graph hears nothing, because the verbs could not answer anyway', - rc == 0 and out == '', f'rc={rc} out={out[:120]}') - - graph(repo, ROWS) - graph(other, [('handle', 'Router.handle', 'method', 'lib/router.js', 9), ('Router', 'Router', 'class', 'lib/router.js', 2)]) - - rc, out, _ = fire(repo, 'Grep', {'pattern': 'TODO|use strict'}) - check('CONTROL: a search that names nothing the graph declares hears nothing (the generic text was acted on 0%)', - rc == 0 and out == '', f'out={out[:120]}') - - rc, out, _ = fire(repo, 'Grep', {'pattern': 'maxRetries'}) - check('CONTROL: a field shares its name with too much config to be the question, so it is not named', - rc == 0 and out == '', f'out={out[:120]}') - - rc, out, _ = fire(repo, 'Grep', {'pattern': r'doWork\('}) - first = ctx(out) - check('a search for a declared method is told that declaration, where it is, and the impact call for it', - rc == 0 and first and 'Worker.doWork' in first and 'src/Worker.java:7' in first and 'impact(name="src/Worker.java:7")' in first - and 'never spell the name' in first, f'out={out[:300]}') - - rc, out, _ = fire(repo, 'Grep', {'pattern': 'Worker'}) - check('said once per session: a later search for another declaration in the same session hears nothing more', - rc == 0 and out == '', f'out={out[:120]}') - - rc, out, _ = fire(other, 'Grep', {'pattern': 'Router', 'path': other}) - check('once per session means per SESSION: a search in another indexed repository hears nothing more either', - rc == 0 and out == '', f'out={out[:120]}') - - rc, out, _ = fire(other, 'Grep', {'pattern': 'Router', 'path': other}, session='s2') - check('a new session is told again, naming the declaration in the repository it searches', - rc == 0 and ctx(out) and 'Router' in ctx(out) and 'lib/router.js:2' in ctx(out), f'out={out[:200]}') - - rc, out, _ = fire(repo, 'Read', {'file_path': os.path.join(repo, 'src', 'Worker.java')}, session='s3') - check('a Read of a file already chosen is not a search and hears nothing', - rc == 0 and out == '', f'out={out[:120]}') - - rc, out, _ = fire(repo, 'Bash', {'command': 'git status'}, session='s3') - check('a shell command that is not a search is not the decision this is about', - rc == 0 and out == '', f'out={out[:120]}') - - rc, out, _ = fire(repo, 'Bash', {'command': 'npm test 2>&1 | grep doWork'}, session='s3') - check('a grep filtering another command\'s output is not a code search', - rc == 0 and out == '', f'out={out[:120]}') - - rc, out, _ = fire(repo, 'Bash', {'command': 'grep -n doWork NOTES.md'}, session='s3') - check('a shell search of a file that is not source is not the decision this is about', - rc == 0 and out == '', f'out={out[:120]}') - - rc, out, _ = fire(repo, 'Bash', {'command': 'grep -rn work src'}, session='s3') - check('CONTROL: a plain lowercase word is as likely prose as a name, and is not named', - rc == 0 and out == '', f'out={out[:120]}') - - rc, out, _ = fire(repo, 'Bash', {'command': f'cd {repo} && grep -rn "def work(" src'}, session='s3') - check('the same word written as code (`def work(`) is a name, and a shell search for it is told', - rc == 0 and ctx(out) and 'src/util.py:1' in ctx(out), f'out={out[:200]}') - - rc, out, _ = fire(repo, 'Grep', {'pattern': r'expr_call\("work"'}, session='s3') - check('CONTROL: a lowercase word that is a string inside code (`expr_call("work"`) is not named, though code is near it', - rc == 0 and out == '', f'out={out[:120]}') - - rc, out, _ = fire(repo, 'Grep', {'pattern': r'\.handle\('}, session='s3') - check('CONTROL: a name declared in more than a handful of places says nothing about which one, and is not named', - rc == 0 and out == '', f'out={out[:120]}') - - rc, out, _ = fire(repo, 'Grep', {'pattern': r'^\.work ref|\.work\b'}, session='s3') - check('CONTROL: a lowercase word behind a dot alone (`\\.json`, a Datalog `.decl`) is an extension or a directive, not named', - rc == 0 and out == '', f'out={out[:120]}') - - rc, out, _ = fire(repo, 'Grep', {'pattern': r'\.work\('}, session='s10') - check('the same word called behind a dot (`.work(`) is a name, and is told', - rc == 0 and ctx(out) and 'src/util.py:1' in ctx(out), f'out={out[:200]}') - - rc, out, _ = fire(repo, 'Grep', {'pattern': 'def render(', 'path': 'lib'}, session='s9') - check('the declaration inside the searched path is the one named, not the first in the repository', - rc == 0 and ctx(out) and 'lib/view.py:5' in ctx(out) and 'src/' not in ctx(out), f'out={out[:200]}') - - rc, out, _ = fire(repo, 'mcp__plugin_axiomcode_axiomcode__impact', {'name': 'Worker.doWork'}, session='s4') - rc2, out2, _ = fire(repo, 'Grep', {'pattern': 'doWork'}, session='s4') - check('an agent that already called the graph through MCP is not told about it afterwards', - rc == 0 and out == '' and rc2 == 0 and out2 == '', f'out={out2[:120]}') - - rc, out, _ = fire(repo, 'Bash', {'command': 'axiomcode impact Worker.doWork'}, session='s5') - rc2, out2, _ = fire(repo, 'Grep', {'pattern': 'doWork'}, session='s5') - check('an agent already calling a verb is not told to call one, then or later', - rc == 0 and out == '' and out2 == '', f'out={out2[:120]}') - - r = subprocess.run([sys.executable, HOOK], input='not json at all', - capture_output=True, text=True, timeout=20, env={**os.environ, 'TMPDIR': TMP}) - check('malformed input costs the agent nothing: silent, exit 0', - r.returncode == 0 and r.stdout.strip() == '', f'rc={r.returncode}') - - r = subprocess.run([sys.executable, HOOK], input='', capture_output=True, text=True, timeout=20, env={**os.environ, 'TMPDIR': TMP}) - check('empty input costs the agent nothing either', r.returncode == 0, f'rc={r.returncode}') - - rc, out, _ = fire(repo, 'Grep', {'pattern': 'doWork'}, session='s6') - check('it never blocks — additionalContext only, never a permissionDecision', - rc == 0 and ctx(out) and 'permissionDecision' not in out, out[:160]) - - open(os.path.join(repo, '.axiomcode', 'out', 'graph.sqlite'), 'w').close() # a graph file with no tables - rc, out, _ = fire(repo, 'Grep', {'pattern': 'doWork'}, session='s7') - check('a graph it cannot read costs the agent nothing: silent, exit 0', rc == 0 and out == '', f'out={out[:120]}') - - ro = tempfile.mkdtemp() - graph(ro, ROWS) - os.chmod(os.path.join(ro, '.axiomcode'), 0o500) # nothing can be written inside the repository - try: - rc, out, _ = fire(ro, 'Grep', {'pattern': 'doWork'}, session='s8') - rc2, out2, _ = fire(ro, 'Grep', {'pattern': 'Worker'}, session='s8') - check('a repository it cannot write to gets its directive once, not before every search', - rc == 0 and ctx(out) and rc2 == 0 and out2 == '', f'first={out[:80]} second={out2[:80]}') - finally: - os.chmod(os.path.join(ro, '.axiomcode'), 0o700) - - # a host that sends no session id: once per directory it runs in, not once for every such session ever - rc, out, _ = fire(ro, 'Grep', {'pattern': 'doWork'}, session=None) - rc2, out2, _ = fire(ro, 'Grep', {'pattern': 'Worker'}, session=None) - rc3, out3, _ = fire(other, 'Grep', {'pattern': 'Router', 'path': other}, session=None) - check('with no session id it is said once where the agent runs, and again in another directory', - ctx(out) and out2 == '' and ctx(out3) and 'lib/router.js:2' in ctx(out3), f'{out[:60]} | {out2[:60]} | {out3[:60]}') - -print() -print(f"{len(checked) - len(fails)} of {len(checked)} promise(s) held" if not fails else f"{len(fails)} FAILED: " + '; '.join(fails)) -sys.exit(1 if fails else 0) diff --git a/tests/enrich_budget.py b/tests/enrich_budget.py deleted file mode 100644 index 6fc429d0..00000000 --- a/tests/enrich_budget.py +++ /dev/null @@ -1,123 +0,0 @@ -#!/usr/bin/env python3 -"""tests/enrich_budget.py — what the Read / Grep enrichment is allowed to cost a session (#1199). - -enrich.py attaches a `graph:` block to a Read or a Grep of source. Each block is small, but a session reads a lot -and every block stays in context for the rest of it, so the hook holds itself to a per-session budget: - - · a declaration is annotated once a session, however its lines are reached again; - · once the budget is spent it says so once, and then nothing; - · the second half of the budget goes only to blocks with an edge into a file the agent has not opened. - -Every silence below has its control: the same read in a fresh session DOES get a block, so a check cannot pass -because the hook had nothing to say. It indexes a small project, so it needs the engine, as run.py does. - - python3 tests/enrich_budget.py -""" -import json, os, subprocess, sys, tempfile - -ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -HOOK = os.path.join(ROOT, 'plugins', 'axiomcode', 'hooks', 'enrich.py') -AX = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts', 'axiomcode') - -# A calls B; B calls back into A only; C calls D. Reading A then B: every edge of B ends in A, a file already open. -SRC = { - 'A.java': 'package app;\npublic class A {\n public void a() { new B().b(); }\n public void x() { }\n}\n', - 'B.java': 'package app;\npublic class B {\n public void b() { new A().x(); }\n}\n', - 'C.java': 'package app;\npublic class C {\n public void c() { new D().d(); }\n}\n', - 'D.java': 'package app;\npublic class D {\n public void d() { }\n}\n', -} - -fails, checked = [], [] -def check(why, cond, detail=''): - checked.append(why) - print(('ok ' if cond else 'FAIL ') + why + (f'\n {detail}' if not cond and detail else '')) - if not cond: - fails.append(why) - - -def read(repo, session, name, budget=100000, **extra): - """fire the hook as after a Read of src/app/; the text the agent would receive""" - fp = extra.pop('file_path', os.path.join(repo, 'src', 'app', name)) - ev = {'hook_event_name': 'PostToolUse', 'tool_name': 'Read', 'tool_input': dict({'file_path': fp}, **extra), - 'cwd': repo, 'session_id': session} - env = dict(os.environ, AXIOMCODE_ENRICH_BUDGET=str(budget)) - r = subprocess.run([sys.executable, HOOK], input=json.dumps(ev), env=env, capture_output=True, text=True, timeout=120) - out = r.stdout.strip() - if not out: - return '' - try: - return json.loads(out)['hookSpecificOutput']['additionalContext'] - except (ValueError, KeyError, TypeError): - return out - - -with tempfile.TemporaryDirectory() as repo: - os.makedirs(os.path.join(repo, 'src', 'app')) - for n, t in SRC.items(): - open(os.path.join(repo, 'src', 'app', n), 'w').write(t) - built = subprocess.run(['bash', AX, 'index', repo, '--lang', 'java', '--src', 'src'], capture_output=True, text=True, timeout=1800) - if not os.path.exists(os.path.join(repo, '.axiomcode', 'out', 'graph.sqlite')): - print('FAIL could not index the project; the engine is needed\n ' + built.stderr.strip()[-300:]) - sys.exit(1) - - # ── a declaration is annotated once ───────────────────────────────────────────────────────────────── - first = read(repo, 's1', 'A.java') - check('a first read of source gets the edges its text does not show', 'graph:' in first and 'B.b' in first, first[:200]) - check('the same read again gets nothing', read(repo, 's1', 'A.java') == '') - check('an overlapping range of the same lines gets nothing either (the key is the declaration, not the arguments)', - read(repo, 's1', 'A.java', offset=2, limit=3) == '') - check('nor does the same file reached through a relative path', - read(repo, 's1', 'A.java', file_path='src/app/A.java') == '') - check('control: a fresh session reading that range is annotated, so the silences above are the rule', - 'graph:' in read(repo, 's1b', 'A.java', offset=2, limit=3)) - - # ── the second half of the budget goes to edges into unopened files ───────────────────────────────── - spent = len(read(repo, 's2', 'A.java')) + 1 # A: its edge into B, a file not opened yet - half = 2 * spent - 2 # a budget whose first half that one block spent - check('past half the budget, a read whose every edge ends in a file already opened gets nothing', - read(repo, 's2', 'B.java', budget=half) == '') - check('control: the same read of B in a fresh session is annotated, so it has edges to show', - 'graph:' in read(repo, 's2b', 'B.java', budget=half)) - c = read(repo, 's2', 'C.java', budget=half) - check('past half the budget, a read with an edge into a file not yet opened still gets its block', - 'graph: C.java' in c and 'C.c' in c, c[:200]) - - # ── spent means silent, after saying so once ──────────────────────────────────────────────────────── - read(repo, 's3', 'A.java', budget=1) - said = read(repo, 's3', 'C.java', budget=1) - check('once the budget is spent the hook says so, once, and names the verbs to ask instead', - 'budget' in said and 'impact' in said and 'B.b' not in said and 'D.d' not in said, said[:200]) - check('and after that it is silent', read(repo, 's3', 'D.java', budget=1) == '') - check('control: that read of D is annotated in a session with budget left', - 'graph:' in read(repo, 's3b', 'D.java', budget=1)) - - # ── a graph answer the agent already has is not repeated by the next read ─────────────────────────── - def answered(session, text): - ev = {'hook_event_name': 'PostToolUse', 'tool_name': 'mcp__plugin_axiomcode_axiomcode__axiomcode_path', - 'tool_input': {'from_': 'A.a', 'to': '*'}, 'tool_response': text, 'cwd': repo, 'session_id': session} - return subprocess.run([sys.executable, HOOK], input=json.dumps(ev), capture_output=True, text=True, timeout=120).stdout.strip() - said = answered('s4', 'A.a src/app/A.java:3\n → [known_edge] B.b src/app/B.java:3') - check('a graph answer itself gets no block', said == '', said[:200]) - check('a read of what that answer already showed gets nothing', read(repo, 's4', 'B.java') == '') - check('control: the same read in a session without that answer is annotated', - 'graph:' in read(repo, 's4b', 'B.java')) - def via_cli(session, text): - ev = {'hook_event_name': 'PostToolUse', 'tool_name': 'Bash', 'tool_input': {'command': 'axiomcode path A.a "*"'}, - 'tool_response': {'stdout': text}, 'cwd': repo, 'session_id': session} - return subprocess.run([sys.executable, HOOK], input=json.dumps(ev), capture_output=True, text=True, timeout=120).stdout.strip() - via_cli('s5', 'C.c src/app/C.java:3') - check('the same holds for an answer asked through the CLI', read(repo, 's5', 'C.java') == '') - - # ── the prompt hook speaks only when the prompt names the code ────────────────────────────────────── - ORIENT = os.path.join(ROOT, 'plugins', 'axiomcode', 'hooks', 'orient.py') - def orient(session, prompt): - ev = {'hook_event_name': 'UserPromptSubmit', 'prompt': prompt, 'cwd': repo, 'session_id': session} - return subprocess.run([sys.executable, ORIENT], input=json.dumps(ev), capture_output=True, text=True, timeout=120).stdout.strip() - meta = orient('o1', 'also are hooks polluting the context when the plugin is used, and is it merged to main?') - check('a prompt that names no code gets nothing from the prompt hook', meta == '', meta[:200]) - named = orient('o2', 'why does B.b never reach D when it is called from the other class?') - check('control: a prompt that names a declaration the graph holds is oriented', 'graph:' in named, named[:200]) - -print() -print(f"{len(checked) - len(fails)} of {len(checked)} check(s) held" if not fails else f"{len(fails)} FAILED: " + '; '.join(fails)) -sys.exit(1 if fails else 0) diff --git a/tests/enrich_lines.py b/tests/enrich_lines.py deleted file mode 100644 index 27a1dafc..00000000 --- a/tests/enrich_lines.py +++ /dev/null @@ -1,296 +0,0 @@ -#!/usr/bin/env python3 -"""tests/enrich_lines.py: what one line of the Read / Grep / Edit enrichment says, where a bare number misled. - - · a caller count of 0 says why where the graph knows: `entry (http)`, `0 resolved, N by name`, `? framework (@X)`; - a method with no signal still reads `0` (the control); - · the Grep preview names production callers before tests (#1507), and a name declared several times lists the base - first with its override count, two different files never print as the same line, two overloads in one file print once, and the rest are counted (#1546); - · a grep run through the shell over another command's output or over files no graph indexes adds nothing, and a - constant-shaped word is not looked up as a callable (#1604); - · an edit that changes no declaration adds nothing, a body-only edit adds one line of reaching tests and the command - that runs them, and a declaration already reported this session is not reported again. - -Every silence has its control: the same hook on a near-miss input DOES speak, so a check cannot pass because the hook -had nothing to say. It indexes a small project, so it needs the engine, as run.py does. - - python3 tests/enrich_lines.py -""" -import json, os, shutil, subprocess, sys, tempfile - -ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -HOOKS = os.path.join(ROOT, 'plugins', 'axiomcode', 'hooks') -AX = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts', 'axiomcode') -P = 'core/src/main/java/app/orders/' - -SRC = { - 'pom.xml': '4.0.0appapp1\n', - P + 'OrderStore.java': 'package app.orders;\n\npublic class OrderStore {\n public String findById(int id) {\n return "order-" + id;\n }\n}\n', - P + 'OrderService.java': ('package app.orders;\n\npublic class OrderService {\n private final OrderStore store = new OrderStore();\n\n' - ' public String show(int id) { return store.findById(id); }\n\n' - ' public String cancel(int id) { return store.findById(id); }\n\n' - ' public String ship(int id) { return store.findById(id); }\n}\n'), - # sorts BEFORE OrderService: an unordered preview named these two and hid the production callers - 'core/src/test/java/app/orders/CheckoutTest.java': ('package app.orders;\n\nimport org.junit.jupiter.api.Test;\n\nclass CheckoutTest {\n' - ' @Test\n void findsAnOrder() { new OrderStore().findById(1); }\n\n' - ' @Test\n void findsAnotherOrder() { new OrderStore().findById(2); }\n}\n'), - P + 'Converter.java': ('package app.orders;\n\npublic interface Converter {\n String convert(Object value);\n\n' - ' abstract class Factory {\n public abstract Converter widgetConverter(String type);\n }\n}\n'), - P + 'JsonConverterFactory.java': ('package app.orders;\n\npublic class JsonConverterFactory extends Converter.Factory {\n' - ' public Converter widgetConverter(String type) { return v -> "json:" + v; }\n}\n'), - P + 'WidgetClient.java': ('package app.orders;\n\npublic class WidgetClient {\n private final java.util.Map registry = new java.util.HashMap();\n' - ' public String sendWidget(Converter.Factory f, Object w) {\n return f.widgetConverter("widget").convert(w);\n }\n' - ' public void prime(Object anything) {\n registry.lookup("jobs").nudgeAll();\n }\n}\n'), - P + 'Jobs.java': ('package app.orders;\n\nimport org.springframework.scheduling.annotation.Scheduled;\n' - 'import org.springframework.web.bind.annotation.GetMapping;\nimport org.springframework.web.bind.annotation.RestController;\n\n' - '@RestController\npublic class Jobs {\n' - ' @GetMapping("/orders")\n public String listOrders() { return new Plain().countAll(); }\n\n' - ' @Scheduled(fixedRate = 1000)\n public void sweepStale() { new Plain().countAll(); }\n\n' - ' public void nudgeAll() { new Plain().countAll(); }\n\n' - ' public void pokeAll() { new Plain().countAll(); }\n}\n'), - # a library base: `run` overrides it, `tickAll` only may (the graph does not hold Runnable's methods) - P + 'Ticker.java': ('package app.orders;\n\npublic class Ticker implements Runnable {\n' - ' @Override\n public void run() { new Plain().countAll(); }\n\n' - ' public void tickAll() { new Plain().countAll(); }\n}\n'), - P + 'Plain.java': ('package app.orders;\n\npublic class Plain {\n public String countAll() { return "1"; }\n' - ' public int unusedCount() { return Integer.parseInt(countAll()); }\n}\n'), - # two overloads in one file: one name to the reader, printed once - P + 'Pricer.java': ('package app.orders;\n\npublic class Pricer {\n public int quoteAll(int n) { return n; }\n\n' - ' public int quoteAll(String s) { return quoteAll(s.length()); }\n}\n'), - # a constant with one reader, a sibling that does not read it, and a second class in the same file - P + 'Limits.java': ('package app.orders;\n\npublic class Limits {\n public static final int MAX_ITEMS = 3;\n\n' - ' public int cap() { return MAX_ITEMS; }\n\n public int floor() { return 1; }\n}\n\n' - 'class Spare {\n int spareCount() { return 2; }\n}\n'), -} -# two test stubs in modules whose paths sort before core/, with the same file name and line -for m in ('json', 'xml'): - SRC[f'adapter-{m}/src/test/java/app/{m}/StubConverterFactory.java'] = ( - f'package app.{m};\n\nimport app.orders.Converter;\n\npublic class StubConverterFactory extends Converter.Factory {{\n' - ' public Converter widgetConverter(String type) { return v -> "stub"; }\n}\n') - -fails, checked = [], [] -def check(why, cond, detail=''): - checked.append(why) - print(('ok ' if cond else 'FAIL ') + why + (f'\n {detail}' if not cond and detail else '')) - if not cond: - fails.append(why) - - -def fire(repo, session, tool, inp, hook='enrich.py', event='PostToolUse'): - ev = {'hook_event_name': event, 'tool_name': tool, 'tool_input': inp, 'cwd': repo, 'session_id': session, 'prompt': 'go on'} - r = subprocess.run([sys.executable, os.path.join(HOOKS, hook)], input=json.dumps(ev), capture_output=True, text=True, timeout=120) - out = r.stdout.strip() - if not out: - return '' - try: - return json.loads(out)['hookSpecificOutput']['additionalContext'] - except (ValueError, KeyError, TypeError): - return out - - -def grep(repo, pattern, session=None): - """the previous grep note (declarations with caller counts), still served with AXIOMCODE_GREP_AID=0""" - os.environ['AXIOMCODE_GREP_AID'] = '0' - try: return fire(repo, session or f'g-{pattern}', 'Grep', {'pattern': pattern}) - finally: os.environ.pop('AXIOMCODE_GREP_AID', None) - - -def grep_aid(repo, pattern, session=None): - """the grep note as served: only what the agent's own grep lines cannot show""" - lines = subprocess.run(['git', 'grep', '-nw', pattern], cwd=repo, capture_output=True, text=True).stdout - return _fire_with(repo, session or f'ga-{pattern}', pattern, lines) - - -def _fire_with(repo, session, pattern, lines): - ev = {'hook_event_name': 'PostToolUse', 'tool_name': 'Grep', 'tool_input': {'pattern': pattern, 'output_mode': 'content'}, - 'tool_response': {'mode': 'content', 'content': lines}, 'cwd': repo, 'session_id': session} - r = subprocess.run([sys.executable, os.path.join(HOOKS, 'enrich.py')], input=json.dumps(ev), capture_output=True, text=True, timeout=120) - try: return json.loads(r.stdout)['hookSpecificOutput']['additionalContext'] if r.stdout.strip() else '' - except (ValueError, KeyError, TypeError): return r.stdout.strip() - - -def bash(repo, command, session=None): - """a grep run through the shell, with the previous note (AXIOMCODE_GREP_AID=0), like grep()""" - os.environ['AXIOMCODE_GREP_AID'] = '0' - try: return fire(repo, session or f'b-{command}', 'Bash', {'command': command}) - finally: os.environ.pop('AXIOMCODE_GREP_AID', None) - - -def line_of(block, name): - return next((l for l in block.splitlines() if name in l), '') - - -def git(repo, *a): - subprocess.run(['git', '-c', 'user.email=t@t', '-c', 'user.name=t', *a], cwd=repo, capture_output=True, check=True) - - -with tempfile.TemporaryDirectory() as repo: - for n, t in SRC.items(): - os.makedirs(os.path.dirname(os.path.join(repo, n)), exist_ok=True) - open(os.path.join(repo, n), 'w').write(t) - git(repo, 'init', '-q'); git(repo, 'add', '-A'); git(repo, 'commit', '-qm', 'init') - built = subprocess.run(['bash', AX, 'index', repo, '--lang', 'java'], capture_output=True, text=True, timeout=1800) - if not os.path.exists(os.path.join(repo, '.axiomcode', 'out', 'graph.sqlite')): - print('FAIL could not index the project; the engine is needed\n ' + built.stderr.strip()[-300:]) - sys.exit(1) - - # ── ← 0 says why ──────────────────────────────────────────────────────────────────────────────────── - g = grep(repo, 'listOrders|sweepStale|nudgeAll|pokeAll|unusedCount') - check('a route handler with no caller reads as an entry point, not as 0', '← entry (http)' in line_of(g, 'Jobs.listOrders'), g) - check('a scheduled method reads as an entry point', '← entry (scheduled)' in line_of(g, 'Jobs.sweepStale'), g) - check('a method whose name is written at an untyped call site counts that site, before the annotation on its class', - '← 0 resolved, 1 by name' in line_of(g, 'Jobs.nudgeAll'), g) - check('a method of a framework-annotated class says which annotation', '← ? framework (@RestController on Jobs)' in line_of(g, 'Jobs.pokeAll'), g) - check('control: an undecorated method nothing calls still reads 0', '← 0 →' in line_of(g, 'Plain.unusedCount'), g) - # the grep-aid note: grep shows no caller for these; it says who enters them, and nothing for the plain one - for nm, want in (('listOrders', 'entered by the runtime (http)'), ('sweepStale', 'entered by the runtime (scheduled)'), - ('pokeAll', 'by the framework (@RestController on Jobs)')): - a = grep_aid(repo, nm) - check(f'grep aid: {nm}, which nothing in the code calls, says who enters it', want in a, a) - a = grep_aid(repo, 'unusedCount') - check('grep aid control: a method nothing calls and nothing enters adds nothing (grep already shows no caller)', a == '', a) - a = grep_aid(repo, 'findById') - check('grep aid control: a name grep found complete and unambiguous adds nothing', a == '', a) - r = fire(repo, 'r1', 'Read', {'file_path': os.path.join(repo, P + 'Ticker.java')}) - check('a Read labels a library override the same way', 'run ←? framework (overrides a library method)' in r, r) - check('a method of a class with a library base that it may not override names the base', 'tickAll ←? framework (extends Runnable)' in r, r) - check('control: a Read of a method with callers still counts them', - 'countAll ←6' in fire(repo, 'r2', 'Read', {'file_path': os.path.join(repo, P + 'Plain.java')})) - - # ── the preview order (#1507) and several declarations (#1546) ─────────────────────────────────────── - g = grep(repo, 'findById') - check('production callers are named before test callers', '← 5 (cancel, ship, …; 2 in tests)' in g, g) - check('and the test callers are not the ones named', 'findsAnOrder' not in g, g) - g = grep(repo, 'widgetConverter') - rows = [l for l in g.splitlines() if l.startswith(' ')] - check('the base declaration comes first, with its override count', - rows and rows[0].lstrip().startswith('Converter.Factory.widgetConverter') and '⇣ 3 override(s)' in rows[0], g) - check('the production override comes before test stubs', len(rows) > 1 and 'JsonConverterFactory.widgetConverter' in rows[1], g) - check('the declarations not shown are counted', '(+2 more declaration(s))' in g, g) - check('two printed lines are never the same', len(rows) == len(set(rows)), g) - g = grep(repo, 'quoteAll') - rows = [l for l in g.splitlines() if l.startswith(' ') and 'Pricer.quoteAll' in l] - check('two overloads in one file print as one line that says so', len(rows) == 1 and '(2 overloads)' in rows[0], g) - sys.path.insert(0, HOOKS); import _graphline - two = _graphline.distinct_paths(['adapter-json/src/test/java/app/json/Stub.java', 'adapter-xml/src/test/java/app/xml/Stub.java']) - check('two same-named files in different modules print with the path that tells them apart', two == ['json/Stub.java', 'xml/Stub.java'], two) - check('control: files with different names print as their names', _graphline.distinct_paths(['a/b/C.java', 'a/b/D.java']) == ['C.java', 'D.java']) - - # ── what the shell greps (#1604) ────────────────────────────────────────────────────────────────────── - check('a grep over another command\'s output adds nothing', bash(repo, "mvn test 2>&1 | grep -E 'findById|FAIL'") == '') - check('a grep over a log file adds nothing', bash(repo, 'grep -n findById build.log') == '') - check('a constant-shaped word is not looked up as a callable', bash(repo, 'grep -rn FIND core/src') == '') - check('a shell grep matches whole names, not prefixes', bash(repo, 'grep -rn findBy core/src') == '') - c = bash(repo, 'grep -rn findById core/src') - check('control: the same name grepped over source is annotated', 'OrderStore.findById' in c, c) - c = grep(repo, 'findBy', session='g-prefix') - check('control: the Grep tool still matches a name by (case-sensitive) prefix', 'OrderStore.findById' in c, c) - - # ── the edit hook ───────────────────────────────────────────────────────────────────────────────────── - f = os.path.join(repo, P + 'OrderStore.java') - orig = open(f).read() - def edit(session, text, hook='enrich.py', event='PostToolUse'): - open(f, 'w').write(text) - return fire(repo, session, 'Edit', {'file_path': f}, hook, event) - out = edit('e1', orig + '\n') - check('an edit that changes no declaration adds nothing', out == '', out) - body = orig.replace('"order-"', '"order:"') - out = edit('e2', body) - check('control: a body-only edit gets one line: the tests that reach it and the command that runs them', - out.count('\n') == 0 and 'body edit of OrderStore.findById: 2 test(s) reach it' in out and 'mvn test -Dtest=CheckoutTest' in out, out) - check('and not the readers, which a body edit cannot break', 'reads / uses it' not in out, out) - out = edit('e2', orig.replace('"order-"', '"order;"')) - check('the same declaration edited again in the session is not reported again', out == '', out) - check('control: in a fresh session it is', 'body edit of' in edit('e3', body)) - out = edit('e4', orig.replace('findById(int id)', 'findById(long id)')) - check('a signature edit keeps its blast radius, with impact answered', 'signature OrderStore.findById' in out - and 'reads / uses it' in out and 'impact unavailable' not in out, out) - open(f, 'w').write(body) - out = fire(repo, 'u1', '', {}, 'changes.py', 'UserPromptSubmit') - check('the prompt-time report of a body edit is the same one line', 'body edit of OrderStore.findById' in out and 'reads / uses it' not in out, out) - open(f, 'w').write(orig) - # a declaration beside it in the file (a sibling, another class) is not a reader: `reads / uses it` counts readers only - f = os.path.join(repo, P + 'Limits.java') - orig = open(f).read() - out = edit('e5', orig.replace(' public static final int MAX_ITEMS = 3;\n\n', '')) - check('a removed constant lists its reader and not the declarations beside it', - 'reads / uses it (1): ' in out and 'Limits.cap' in out and 'Limits.floor' not in out and 'Spare.spareCount' not in out, out) - open(f, 'w').write(orig) - -# ── what a Read of a script says: a call in the text read is not an edge the text does not show ───────────── -PY = { - 'tool.py': ('from lib import fetch\n\n\n' - 'def helper(x):\n return fetch(x)\n\n\n' - 'def main():\n return helper(1)\n\n\n' - 'def untyped(o):\n return o.go()\n\n\n' - 'if __name__ == "__main__":\n main()\n'), - 'lib.py': ('def fetch(x):\n return x\n\n\n' - 'def untyped2(o):\n return o.go()\n'), - 'app.py': ('from lib import untyped2\n\n\ndef run(o):\n return untyped2(o)\n'), - 'many.py': ''.join(f'def f{i}(o):\n return o.go()\n\n\n' for i in range(7)) - + 'g1 = lambda o: o.a()\ng2 = lambda o: o.b()\n\n\n' - # an owner-qualified one (`Walker.`) is as anonymous as a bare one - + 'class Walker:\n h = lambda self, o: o.c()\n', - 'use_many.py': 'import many\n\n\ndef use(o):\n' + ''.join(f' many.f{i}(o)\n' for i in range(7)) - + ' many.g1(o)\n many.g2(o)\n', -} -with tempfile.TemporaryDirectory() as repo: - for n, t in PY.items(): open(os.path.join(repo, n), 'w').write(t) - git(repo, 'init', '-q'); git(repo, 'add', '-A'); git(repo, 'commit', '-qm', 'init') - subprocess.run(['bash', AX, 'index', repo, '--lang', 'python'], capture_output=True, text=True, timeout=1800) - if not os.path.exists(os.path.join(repo, '.axiomcode', 'out', 'graph.sqlite')): - check('the python project indexes', False) - else: - tool = os.path.join(repo, 'tool.py') - r = fire(repo, 'p1', 'Read', {'file_path': tool}) - check('a whole-script read does not list its own top level as a caller', '' not in r, r) - check('control: the cross-file callee it cannot show is still there', 'helper ← callers in range →1' in r, r) - check('a declaration whose only caller is in the lines read does not read as ←0', 'helper ←0' not in r, r) - r = fire(repo, 'p2', 'Read', {'file_path': tool, 'offset': 8, 'limit': 3}) - check('control: a range that leaves out the top-level call names it, at the line of the call', - 'main L8' in r and 'tool. L17' in r, r) - r = fire(repo, 'p3', 'Read', {'file_path': tool, 'offset': 12, 'limit': 3}) - check('a block of nothing but unresolved counts is not emitted', r == '', r) - r = fire(repo, 'p4', 'Read', {'file_path': os.path.join(repo, 'lib.py'), 'offset': 5, 'limit': 3}) - check('control: the same unresolved call on a declaration with a caller is kept', - 'untyped2 L5 ← run' in r and 'unresolved' in r, r) - r = fire(repo, 'p5', 'Read', {'file_path': os.path.join(repo, 'many.py')}) - more = line_of(r, '+') - check('anonymous functions, bare or owner-qualified, are counted in +N more but not named', - ('+5 more' in more or '+4 more' in more) and '' not in more, r) - check('control: the named ones left over are still named there', 'f' in more.split(':', 1)[-1], r) - -# ── one reader for "why nothing calls it" (graph_sql.no_caller_reasons), and callers through an interface ────────── -CASES = os.path.join(ROOT, 'tests', 'cases') -with tempfile.TemporaryDirectory() as repo: - shutil.copytree(os.path.join(CASES, 'python', 'why-no-caller-one-reader', 'src'), os.path.join(repo, 'src')) - git(repo, 'init', '-q'); git(repo, 'add', '-A'); git(repo, 'commit', '-qm', 'init') - subprocess.run(['bash', AX, 'index', repo, '--lang', 'python'], capture_output=True, text=True, timeout=1800) - if not os.path.exists(os.path.join(repo, '.axiomcode', 'out', 'graph.sqlite')): - check('the reader project indexes', False) - else: - g = grep(repo, 'get_standings|refresh_board|load_user|_unused_helper', session='g-why') - check('a caching wrapper is not the reason: the Grep line counts the call sites that write the name, as impact does', - '← 0 resolved, 1 by name' in line_of(g, 'get_standings') and 'memoize' not in g, g) - check('near-miss control: a library decoration a framework reads is still the reason', - '← ? framework (@app_hooks.before_request)' in line_of(g, 'load_user'), g) - check('control: a private helper with no reason still reads 0', '← 0 →' in line_of(g, '_unused_helper'), g) -with tempfile.TemporaryDirectory() as repo: - shutil.copytree(os.path.join(CASES, 'java', 'callers-through-the-interface', 'src'), os.path.join(repo, 'src')) - git(repo, 'init', '-q'); git(repo, 'add', '-A'); git(repo, 'commit', '-qm', 'init') - subprocess.run(['bash', AX, 'index', repo, '--lang', 'java'], capture_output=True, text=True, timeout=1800) - if not os.path.exists(os.path.join(repo, '.axiomcode', 'out', 'graph.sqlite')): - check('the interface project indexes', False) - else: - rd = lambda f, sid: fire(repo, sid, 'Read', {'file_path': os.path.join(repo, 'src', 'app', *f.split('/'))}) - r = rd('store/Store.java', 'r-iface') - check('an interface method whose callers hold an interface-typed field lists them, as impact does (#1542)', - 'Store.save L4 ← CtorIface.place, FieldIface.place' in r, r) - r = rd('widgets/WidgetService.java', 'r-iface2') - check('a caller through the interface is listed beside a resolved one', 'WidgetReport.line' in r and 'WidgetController.get' in r, r) - r = rd('shapes/Circle.java', 'r-area') - check('near-miss control: a caller typed on the other implementation is not a caller of this one', - 'Circle.area' in r and 'squareOnly' not in r, r) - -print() -print(f"{len(checked) - len(fails)} of {len(checked)} check(s) held" if not fails else f"{len(fails)} FAILED: " + '; '.join(fails)) -sys.exit(1 if fails else 0) diff --git a/tests/hook_languages.py b/tests/hook_languages.py index 522d4523..16d7a31d 100644 --- a/tests/hook_languages.py +++ b/tests/hook_languages.py @@ -1,11 +1,11 @@ #!/usr/bin/env python3 -"""tests/hook_languages.py: the edit hooks speak for every language the index reads, from one table. +"""tests/hook_languages.py: the edit hook and the tests verb speak for every language the index reads, from one table. Each hook kept its own list of source extensions, and the edit hooks' lists had no `.cs`: a C# edit got no graph line while a C# Read and Grep did. The lists now come from one table (_where.BY_EXT), so this checks the hooks on a C# project, where the old lists were silent: - · a body edit to a C# method gets the one line of reaching tests and the command that runs them (enrich.py); + · after a body edit to a C# method, `axiomcode tests` lists the reaching tests and the command that runs them; · a signature edit to a C# method gets its blast radius BEFORE it lands (changes.py, PreToolUse); · a test declared in an abstract C# base runs as the class that extends it, so the command filters on that class, not on the base, which `dotnet test --filter FullyQualifiedName~` would never match; @@ -18,7 +18,7 @@ """ import json, os, subprocess, sys, tempfile -# the directive's once-per-session stamp lives in the temp directory, keyed on the session +# hook state lives in the temp directory, keyed on the session os.environ['TMPDIR'] = tempfile.mkdtemp(prefix='ax-hooks-') ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) HOOKS = os.path.join(ROOT, 'plugins', 'axiomcode', 'hooks') @@ -82,10 +82,12 @@ def git(repo, *a): check('a signature edit to a C# method gets its blast radius before it lands', 'about to change' in out and 'Repo.Load' in out and 'Service.Run' in out, out) - # PostToolUse: a body-only edit gets one line, the tests and the command + # a body-only edit: `axiomcode tests` names the reaching tests and the command open(f, 'w').write(orig.replace('x + 1', 'x + 2')) - out = fire(repo, 'c2', 'Edit', {'file_path': f}, 'enrich.py', 'PostToolUse') - check('a body edit to a C# method gets the line of tests that reach it', 'body edit of Repo.Save' in out, out) + r = subprocess.run(['bash', AX, 'tests', repo], capture_output=True, text=True, timeout=600, + env=dict(os.environ, AXIOMCODE_NO_REFRESH='1')) + out = r.stdout + r.stderr + check('after a body edit to a C# method, the tests verb lists the tests that reach it', 'SqlRepoTests' in out, out[-800:]) check('the command runs the class that extends the abstract base that declares the test', 'FullyQualifiedName~SqlRepoTests' in out, out) check('and never the abstract base, which no inherited test is named after', 'FullyQualifiedName~RepoContractTests' not in out, out) @@ -95,9 +97,8 @@ def git(repo, *a): p = os.path.join(repo, 'src/App/App.csproj') t = open(p).read() open(p, 'w').write(t.replace('net8.0', 'net9.0')) - out = fire(repo, 'c3', 'Edit', {'file_path': p}, 'enrich.py', 'PostToolUse') out2 = fire(repo, 'c3', 'Edit', {'file_path': p, 'old_string': 'net9.0', 'new_string': 'net8.0'}, 'changes.py', 'PreToolUse') - check('control: an edit to a .csproj says nothing', out == '' and out2 == '', out + out2) + check('control: an edit to a .csproj says nothing', out2 == '', out2) open(p, 'w').write(t) sys.path.insert(0, HOOKS) diff --git a/tests/hook_rebase.py b/tests/hook_rebase.py index a1ff5fc4..58c9237a 100644 --- a/tests/hook_rebase.py +++ b/tests/hook_rebase.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""tests/hook_rebase.py: after a rebase, a pull or a checkout, an edit report names only what that edit changed. +"""tests/hook_rebase.py: after a rebase or a checkout, `changed` and the pre-edit report name only what the edits changed. The edit hook read the edited file against the baseline (the commit the graph was built from), which moves only when the background refresher has rebuilt HEAD's text: minutes on a real tree, never with refresh off. So after `git rebase` every @@ -7,11 +7,9 @@ lines landing on another text, and `changed` counted the new commits as the agent's own. Checked here with the refresher off, which is the moment between the rebase and the refresh: - a rebase brings upstream edits, then one local edit only the local edit is reported, the base move is said once, - and `changed` reads against the new HEAD - control an edit before any move says nothing about a base - a real multi-line local edit (a Write, no host payload) the signature and the body it changed are both reported - a checkout to another branch, then an edit only that edit, and the move is said + a rebase brings upstream edits, then local edits `changed` lists the local edits only and says the base moved + a real multi-line local edit (a Write) the signature it changes is reported before it lands + (changes.py), with nothing upstream did `changed --range` a local branch left behind the remote it was rebased onto reads from the remote's fork, with a note control an explicit commit range is read exactly as written @@ -26,6 +24,8 @@ ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) HOOKS = os.path.join(ROOT, 'plugins', 'axiomcode', 'hooks') AX = os.path.join(ROOT, 'bin', 'axiomcode') +# `changed` is internal: the installed command routes only the public verbs, the dispatcher still serves it +DISPATCH = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts', 'axiomcode') ENV = dict(os.environ, AXIOMCODE_ENGINE=ROOT, AXIOMCODE_NO_REFRESH='1') G = ('git', '-c', 'user.email=t@t', '-c', 'user.name=t') @@ -50,14 +50,9 @@ def fire(repo, sid, hook, event, tool, inp, resp=None): try: return json.loads(r.stdout)['hookSpecificOutput']['additionalContext'] except Exception: return r.stdout + r.stderr -def edit(repo, sid, rel, old, new, payload=True): - """an Edit as the host runs it: PreToolUse, the change, PostToolUse (with the host's originalFile when `payload`)""" - p = os.path.join(repo, rel); inp = dict(file_path=p, old_string=old, new_string=new) - fire(repo, sid, 'changes.py', 'PreToolUse', 'Edit', inp) - before = open(p).read(); assert before.count(old) == 1, old +def edit(repo, rel, old, new): + p = os.path.join(repo, rel); before = open(p).read(); assert before.count(old) == 1, old open(p, 'w').write(before.replace(old, new, 1)) - return fire(repo, sid, 'enrich.py', 'PostToolUse', 'Edit', inp, - dict(filePath=p, oldString=old, newString=new, originalFile=before) if payload else None) def write(repo, rel, text): p = os.path.join(repo, rel); os.makedirs(os.path.dirname(p), exist_ok=True); open(p, 'w').write(text) @@ -83,11 +78,6 @@ def main(): built = sh(repo, AX, 'index', '.', '--lang', 'python') check(built.returncode == 0 and os.path.exists(os.path.join(repo, '.axiomcode', 'out', 'graph.sqlite')), 'the graph builds', built.stdout + built.stderr) - # control: an edit before anything moved says nothing about a base - out = edit(repo, 's0', 'app/eng.py', 'return x\n', 'return x - 0\n') - check('stop' in out and 'base moved' not in out, 'control: an edit with HEAD unmoved reports it and names no base move', out) - sh(repo, 'git', 'checkout', '-q', '--', 'app/eng.py') - # upstream moves on (through the remote; the local main is never updated), and feature is rebased onto it up = os.path.join(work, 'up'); sh(work, 'git', 'clone', '-q', origin, up) write(up, 'app/eng.py', UPSTREAM); commit(up, 'upstream'); sh(up, 'git', 'push', '-q', 'origin', 'main') @@ -95,53 +85,33 @@ def main(): r = sh(repo, 'git', 'rebase', 'origin/main') check(r.returncode == 0, 'the branch rebases onto the moved remote', r.stderr) - # one local edit, to run's body - out = edit(repo, 's1', 'app/eng.py', 'y = x + 1', 'y = x + 5') - check('body edit of run:' in out, 'after a rebase, the local edit to run is reported', out) - check('_engine_hash' not in out and 'helper' not in out, "upstream's _engine_hash and helper are not reported as this edit", out) - check(out.count('the base moved') == 1 and '1 commit(s)' in out, 'the base move is said once, with the upstream commit count', out) - out2 = edit(repo, 's1', 'app/eng.py', 'return x\n', 'return x * 1\n') - check('stop' in out2 and 'base moved' not in out2, 'the next edit reports itself and does not repeat the base move', out2) - rc = sh(repo, AX, 'changed', '.') + # two local edits, to run's body and to stop's + edit(repo, 'app/eng.py', 'y = x + 1', 'y = x + 5') + edit(repo, 'app/eng.py', 'return x\n', 'return x * 1\n') + rc = sh(repo, 'bash', DISPATCH, 'changed', '.') check('run' in rc.stdout and 'stop' in rc.stdout and '_engine_hash' not in rc.stdout and 'helper' not in rc.stdout, '`changed` after the rebase lists the uncommitted edits only, not what upstream changed', rc.stdout + rc.stderr) check('the base moved' in rc.stdout, '`changed` says the base moved', rc.stdout) - # the same edit with no host payload: the before-text comes from the PreToolUse snapshot - out3 = edit(repo, 's1b', 'app/eng.py', 'y = x + 5', 'y = x + 6', payload=False) - check('run' in out3 and '_engine_hash' not in out3 and 'helper' not in out3, 'without the host payload, the PreToolUse snapshot keeps the report to this edit', out3) sh(repo, 'git', 'checkout', '-q', '--', 'app/eng.py') - # a real multi-line local edit, written whole with no host payload: a signature and a body, both reported + # a real multi-line local edit, written whole: the signature it changes is reported before it lands p = os.path.join(repo, 'app/eng.py'); cur = open(p).read() new = cur.replace('_engine_hash(a, b, c=0)', '_engine_hash(a, b, c=0, d=1)').replace('a + b + c', 'a + b + c + d').replace('y = x + 1', 'y = x + 7') inp = dict(file_path=p, content=new) - pre = fire(repo, 's2', 'changes.py', 'PreToolUse', 'Write', inp); open(p, 'w').write(new) - out = fire(repo, 's2', 'enrich.py', 'PostToolUse', 'Write', inp) + pre = fire(repo, 's2', 'changes.py', 'PreToolUse', 'Write', inp) check('signature _engine_hash' in pre and '+d' in pre and 'helper' not in pre, 'a multi-line local edit: the signature it changes is reported before it lands, with the parameter, and nothing upstream did', pre) - check('run' in out and 'helper' not in out, 'a multi-line local edit: the body it changed is reported, and nothing upstream did', out) - sh(repo, 'git', 'checkout', '-q', '--', 'app/eng.py') - - # a checkout to another branch, then an edit (no host payload, no PreToolUse: the edit is undone to find the before) - sh(repo, 'git', 'checkout', '-qb', 'side', 'main') - write(repo, 'app/eng.py', ENG + '\n\ndef side(z):\n return z\n'); commit(repo, 'side') - p = os.path.join(repo, 'app/eng.py'); before = open(p).read(); open(p, 'w').write(before.replace('return x\n', 'return x + 2\n')) - out = fire(repo, 's3', 'enrich.py', 'PostToolUse', 'Edit', dict(file_path=p, old_string='return x\n', new_string='return x + 2\n')) - check('body edit of stop:' in out and '_engine_hash' not in out and 'side' not in out.replace('checkout', ''), - 'after a checkout, only the edit to stop is reported, not what the other branch holds', out) - check(out.count('the base moved') == 1, 'the checkout is said once as a base move', out) - sh(repo, 'git', 'checkout', '-q', '--', 'app/eng.py'); sh(repo, 'git', 'checkout', '-q', 'feature') # `changed --range`: the local main is behind origin/main, which feature was rebased onto - rc = sh(repo, AX, 'changed', '.', '--range', 'main..HEAD') + rc = sh(repo, 'bash', DISPATCH, 'changed', '.', '--range', 'main..HEAD') check('other' in rc.stdout and '_engine_hash' not in rc.stdout and 'helper' not in rc.stdout, "--range main..HEAD, main left behind origin/main: only the branch's own commit", rc.stdout + rc.stderr) check('main is behind origin/main' in rc.stdout, 'the note says which fork it read from and why', rc.stdout) base = sh(repo, 'git', 'rev-parse', 'main').stdout.strip() - rc = sh(repo, AX, 'changed', '.', '--range', f'{base}..HEAD') + rc = sh(repo, 'bash', DISPATCH, 'changed', '.', '--range', f'{base}..HEAD') check('other' in rc.stdout and ('_engine_hash' in rc.stdout or 'helper' in rc.stdout) and 'behind' not in rc.stdout, 'control: an explicit commit range is read as written, upstream commit included', rc.stdout + rc.stderr) - rc = sh(repo, AX, 'changed', '.', '--range', 'origin/main..HEAD') + rc = sh(repo, 'bash', DISPATCH, 'changed', '.', '--range', 'origin/main..HEAD') check('other' in rc.stdout and '_engine_hash' not in rc.stdout and 'range base' not in rc.stdout, 'control: a range from the up-to-date remote needs no note', rc.stdout + rc.stderr) finally: diff --git a/tests/hooks_from_path.py b/tests/hooks_from_path.py index 22906553..41da9ac8 100644 --- a/tests/hooks_from_path.py +++ b/tests/hooks_from_path.py @@ -11,25 +11,18 @@ ws/app/vendored -> ws/plain a symlink inside the indexed project to a tree with no graph Promises, each with a near-miss control: - · a Read, a Grep with path=, a shell `sed -n` by absolute path and a `cd && grep` from ws/ are enriched, the - Read exactly as it is from inside ws/app; - · the state and budget files are kept in the repository's .axiomcode, not the working directory's; - · the directive stays silent on a shell `cat` of a file that is not source, as it is on a Read of one; - · controls: a path with no graph anywhere above it, a path ABOVE the indexed root, and a symlink out of the indexed - tree all stay silent; a symlink INTO it answers from the real graph; + · a signature edit by absolute path from ws/ gets its blast radius (changes.py, PreToolUse), as it does from inside + ws/app; + · controls: a path with no graph anywhere above it and a symlink out of the indexed tree stay silent; a symlink + INTO it answers from the real graph; · the prompt hook tells a C#-only tree with no graph that one can be built (#1453), and a workspace above an indexed tree is not told it has no graph; · finding the graph costs well under the hooks' budget: < 20 ms per lookup, cached per directory for the session. python3 tests/hooks_from_path.py """ -# this suite checks WHERE a hook finds its graph; the previous grep note always speaks when it does, so it is the proof. -# The grep-aid note is silent whenever grep was complete, which proves nothing about the lookup. -import os as _os -_os.environ['AXIOMCODE_GREP_AID'] = '0' import json, os, shutil, subprocess, sys, tempfile, time -# the directive's once-per-session stamp lives in the temp directory, keyed on the session: a run of its own, or a -# second run of this script reuses the first run's session ids and hears nothing +# hook state lives in the temp directory, keyed on the session os.environ['TMPDIR'] = tempfile.mkdtemp(prefix='ax-hooks-') ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -53,7 +46,7 @@ def check(why, cond, detail=''): fails.append(why) -def fire(cwd, session, tool, inp, hook='enrich.py', event='PostToolUse', **extra): +def fire(cwd, session, tool, inp, hook='changes.py', event='PreToolUse', **extra): ev = dict({'hook_event_name': event, 'tool_name': tool, 'tool_input': inp, 'cwd': cwd, 'session_id': session}, **extra) r = subprocess.run([sys.executable, os.path.join(HOOKS, hook)], input=json.dumps(ev), capture_output=True, text=True, timeout=120) out = r.stdout.strip() @@ -87,51 +80,22 @@ def write(base, files): os.symlink(plain, os.path.join(app, 'vendored')) store, svc = os.path.join(app, P, 'OrderStore.java'), os.path.join(app, P, 'OrderService.java') - # ── Read / Grep / shell from a directory with no graph ──────────────────────────────────────────────── - inside = fire(app, 'in', 'Read', {'file_path': store}) - check('control: a Read from inside the indexed project is enriched', 'findById' in inside and 'graph:' in inside, inside) - outside = fire(ws, 'ws1', 'Read', {'file_path': store}) - check('a Read by absolute path from a directory with no graph gets the same block as from inside', outside == inside, outside) - g = fire(ws, 'ws2', 'Grep', {'pattern': 'findById', 'path': app}) - check('a Grep with path= the indexed project gets the caller preview', 'OrderStore.findById' in g and '← 2' in g, g) - s = fire(ws, 'ws3', 'Bash', {'command': f'sed -n 1,40p {store}'}) - check('a shell `sed -n` of a file by absolute path is enriched', 'findById' in s, s) - c = fire(ws, 'ws4', 'Bash', {'command': f'cd {app} && grep -rn findById src'}) - check('a `cd && grep` is enriched', 'OrderStore.findById' in c, c) - rel = fire(ws, 'ws5', 'Read', {'file_path': os.path.relpath(store, ws)}) - check('a relative file_path is resolved against the session directory', rel == inside, rel) - - # ── state is per repository ─────────────────────────────────────────────────────────────────────────── - check('the session state is kept in the repository it is about', - os.path.exists(os.path.join(app, '.axiomcode', 'hooks-state-ws1.json')), os.listdir(os.path.join(app, '.axiomcode'))) + # ── a signature edit, from a directory with no graph ───────────────────────────────────────────────── + # the edit is applied to a copy, so the file never changes and every call below sees the same source + def sig(path): return {'file_path': path, 'old_string': 'public String findById(int id)', 'new_string': 'public String findById(long id)'} + inside = fire(app, 'in', 'Edit', sig(store)) + check('control: a signature edit from inside the indexed project gets its blast radius', 'findById' in inside and 'OrderService' in inside, inside) + outside = fire(ws, 'ws1', 'Edit', sig(store)) + check('the same edit by absolute path from a directory with no graph gets the same report', outside == inside, outside) check('and nothing is written in the working directory', not os.path.exists(os.path.join(ws, '.axiomcode'))) - again = fire(ws, 'ws1', 'Read', {'file_path': os.path.join(ws, 'link', P, 'OrderStore.java')}) - check('the per-session dedup holds across two spellings of one file (it is one repository\'s state)', again == '', again) # ── controls: silent where no graph is above the path ──────────────────────────────────────────────── - check('control: a Read of a file with no graph anywhere above it stays silent', - fire(ws, 'c1', 'Read', {'file_path': os.path.join(plain, P, 'OrderStore.java')}) == '') - check('control: the same file through the SESSION directory of an indexed project stays silent', - fire(app, 'c2', 'Read', {'file_path': os.path.join(plain, P, 'OrderStore.java')}) == '') - check('control: a Grep of a path above the indexed root stays silent', fire(ws, 'c3', 'Grep', {'pattern': 'findById', 'path': ws}) == '') - check('control: a Grep with no path from a directory with no graph stays silent', fire(ws, 'c4', 'Grep', {'pattern': 'findById'}) == '') - check('control: a shell grep of a tree with no graph stays silent', fire(ws, 'c5', 'Bash', {'command': f'grep -rn findById {plain}/src'}) == '') - ln = fire(ws, 'c6', 'Read', {'file_path': os.path.join(ws, 'link', P, 'OrderStore.java')}) + check('control: the edit to a file with no graph anywhere above it stays silent', + fire(ws, 'c1', 'Edit', sig(os.path.join(plain, P, 'OrderStore.java'))) == '') + ln = fire(ws, 'c6', 'Edit', sig(os.path.join(ws, 'link', P, 'OrderStore.java'))) check('a symlink INTO the indexed project answers from its real graph', ln == inside, ln) check('control: a symlink OUT of the indexed project to a tree with no graph stays silent', - fire(app, 'c7', 'Read', {'file_path': os.path.join(app, 'vendored', P, 'OrderStore.java')}) == '') - - # ── the directive (PreToolUse) ──────────────────────────────────────────────────────────────────────── - # it speaks once per session (stamped in the temp directory), so each check gets a session no earlier run used - sid = lambda s: f'{s}-{os.getpid()}-{int(time.time() * 1000)}' - d = fire(ws, sid('d1'), 'Grep', {'pattern': 'findById', 'path': app}, hook='direct.py', event='PreToolUse') - check('the directive speaks before a Grep by absolute path from a directory with no graph', 'impact(name=' in d and 'OrderStore.java' in d, d) - check('control: the directive is silent before a Grep of a tree with no graph', - fire(ws, sid('d2'), 'Grep', {'pattern': 'findById', 'path': plain}, hook='direct.py', event='PreToolUse') == '') - check('the directive is silent on a shell grep of a file that is not source', - fire(app, sid('d3'), 'Bash', {'command': 'grep -n findById notes.md'}, hook='direct.py', event='PreToolUse') == '') - d = fire(app, sid('d4'), 'Bash', {'command': f'grep -n findById {os.path.relpath(store, app)}'}, hook='direct.py', event='PreToolUse') - check('control: the directive speaks on a shell grep of a source file for a declared method', 'impact(name=' in d, d) + fire(app, 'c7', 'Edit', sig(os.path.join(app, 'vendored', P, 'OrderStore.java'))) == '') # ── orientation ────────────────────────────────────────────────────────────────────────────────────── for i in range(30): diff --git a/tests/hosts.py b/tests/hosts.py index e581c385..2be577ae 100644 --- a/tests/hosts.py +++ b/tests/hosts.py @@ -17,8 +17,7 @@ python3 tests/hosts.py """ import json, os, shutil, subprocess, sys, tempfile -# the directive's once-per-session stamp lives in the temp directory, keyed on the session: a run of its own, or a -# second run of this script reuses the first run's session ids and hears nothing +# hook state lives in the temp directory, keyed on the session os.environ['TMPDIR'] = tempfile.mkdtemp(prefix='ax-hooks-') ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -128,39 +127,6 @@ def said_cursor(out, event): sys.exit(1) lib = os.path.join(repo, 'lib.py') - # PostToolUse Read: enrich.py adds the edges the file does not show. - rc, out = fire('enrich.py', original('PostToolUse', 'Read', {'file_path': lib}, repo, 'e1')) - rc2, out2 = fire('enrich.py', cursor('postToolUse', 'Read', {'file_path': lib}, repo, 'e2'), cursor=True) - first = said_original(out) - check('enrich after a Read: the original host hears the callers the file does not show', - rc == 0 and 'test_greet' in first, out[:160]) - check('enrich after a Read: Cursor hears the same lines', rc2 == 0 and first and said_cursor(out2, 'postToolUse') == first, - out2[:160]) - - rc3, out3 = fire('enrich.py', gemini('AfterTool', 'read_file', {'file_path': lib}, repo, 'e3')) - check('enrich after read_file: Gemini hears the same lines', - rc3 == 0 and first and said_gemini(out3, 'AfterTool') == first, out3[:160]) - - # PostToolUse on a shell read: Cursor calls the tool Shell. `cat` of a source file is enriched as a Read. - grep = {'command': 'cat lib.py'} - rc, out = fire('enrich.py', original('PostToolUse', 'Bash', grep, repo, 'g1')) - rc2, out2 = fire('enrich.py', cursor('postToolUse', 'Shell', grep, repo, 'g2'), cursor=True) - first = said_original(out) - check('enrich after `cat` in a shell: the original host hears the callers', rc == 0 and 'test_greet' in first, - out[:160]) - check("enrich after `cat` in a shell: Cursor's Shell is the same tool as Bash", - rc2 == 0 and first and said_cursor(out2, 'postToolUse') == first, out2[:160]) - - rc3, out3 = fire('enrich.py', gemini('AfterTool', 'run_shell_command', grep, repo, 'g3')) - check("enrich after `cat` in a shell: Gemini's run_shell_command is the same tool as Bash", - rc3 == 0 and first and said_gemini(out3, 'AfterTool') == first, out3[:160]) - - # PreToolUse: Cursor's preToolUse output has no context field, so the directive stays silent there. - rc, out = fire('direct.py', original('PreToolUse', 'Grep', {'pattern': r'greet\('}, repo, 'd1')) - rc2, out2 = fire('direct.py', cursor('preToolUse', 'Grep', {'pattern': r'greet\('}, repo, 'd2'), cursor=True) - check('direct: the original host hears the directive', rc == 0 and 'impact' in said_original(out), out[:160]) - check('direct: Cursor hears nothing, since preToolUse cannot carry context', rc2 == 0 and out2 == '', out2[:160]) - # UserPromptSubmit with a graph: changes.py and orient.py answer in Cursor's shape or not at all. for hook in ('changes.py', 'orient.py'): rc2, out2 = fire(hook, cursor('beforeSubmitPrompt', None, {}, repo, 'p1', prompt='what calls greet'), diff --git a/tests/mcp_first.py b/tests/mcp_first.py index 20ba03d0..197d58fe 100644 --- a/tests/mcp_first.py +++ b/tests/mcp_first.py @@ -2,7 +2,7 @@ """tests/mcp_first.py — every surface an agent reads before its first call names the MCP tool first (#1425). `axiomcode install` pre-approves the plugin's MCP tools and nothing else. The skill's description, the -prompt-time orientation and the pre-search directive used to spell the call as a shell command, with the tool +prompt-time orientation used to spell the call as a shell command, with the tool in parentheses or not at all, so an agent took the shell spelling; under the install's permissions that first call is the one that stops for a prompt, and in a headless session it is denied outright. @@ -13,8 +13,8 @@ python3 tests/mcp_first.py indexes one case, so it needs the engine, as hosts.py does """ import json, os, re, shutil, subprocess, sys, tempfile -# the directive's once-per-session stamp lives in the temp directory, keyed on the session: a run of its own, or a -# second run of this script reuses the first run's session ids and hears nothing +# hook state lives in the temp directory, keyed on the session: a run of its own, or a second run of +# this script reusing the first run's session ids, must start clean os.environ['TMPDIR'] = tempfile.mkdtemp(prefix='ax-hooks-') ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -62,19 +62,12 @@ def fire(hook, ev): if m: tool_first('SKILL.md description', ' '.join(m.group(1).split()), ('impact', 'path', 'tests')) -# 2. the directive before the first search for a name the graph declares -with tempfile.TemporaryDirectory() as repo: - import sqlite3 - os.makedirs(os.path.join(repo, '.axiomcode', 'out')) - con = sqlite3.connect(os.path.join(repo, '.axiomcode', 'out', 'graph.sqlite')) - con.execute("CREATE TABLE symbols(name TEXT, display TEXT, kind TEXT, file TEXT, line INT, is_test INT)") - con.execute("INSERT INTO symbols VALUES ('findOrder', 'Repo.findOrder', 'method', 'src/Repo.java', 4, 0)") - con.commit(); con.close() - rc, said = fire('direct.py', {'hook_event_name': 'PreToolUse', 'tool_name': 'Grep', - 'tool_input': {'pattern': 'findOrder'}, 'cwd': repo, 'session_id': 'm1'}) - check('direct: the first search for a declared name hears the directive', rc == 0 and bool(said), f'rc={rc}') - # only the verbs that answer a search: tests is about an edit, not about what a grep looks for - tool_first('direct', said, ('impact', 'path')) +# 2. a search runs untouched: no PreToolUse hook is wired on Read, Grep, Glob or Bash — what the graph +# answers only when the agent asks it, through the MCP tools or the CLI +hooks = json.load(open(os.path.join(HOOKS, 'hooks.json')))['hooks'] +pre = ' '.join(g.get('matcher', '') for g in hooks.get('PreToolUse', [])) +check('no PreToolUse hook speaks before a search (Read/Grep/Glob/Bash)', + not re.search(r'\b(Read|Grep|Glob|Bash)\b', pre), pre) # 3. the orientation on the first prompt, both branches it can reach: a change question and a how-question with tempfile.TemporaryDirectory() as work: diff --git a/tests/python_names.py b/tests/python_names.py index ff3965d7..d463bfba 100644 --- a/tests/python_names.py +++ b/tests/python_names.py @@ -64,7 +64,7 @@ def calls(): # a hook: stdin reaches it, and its exit status comes back unchanged (exit 2 is how a hook blocks) ev = json.dumps({'tool_name': 'Grep', 'tool_input': {'pattern': 'f'}, 'cwd': tmp, 'session_id': 's1'}) - r = subprocess.run(['node', RUNJS, 'direct.py'], input=ev, env=placeholder, capture_output=True, text=True, timeout=60) + r = subprocess.run(['node', RUNJS, 'changes.py'], input=ev, env=placeholder, capture_output=True, text=True, timeout=60) check('a hook runs under `python` when python3 is the placeholder', r.returncode == 0 and 'import sys' in calls(), f'rc={r.returncode} err={r.stderr[-300:]}') r = subprocess.run(['node', RUNJS, 'no-such-hook.py'], input='{}', env=placeholder, capture_output=True, text=True, timeout=60) @@ -82,7 +82,7 @@ def calls(): only_node = os.path.join(tmp, 'only-node'); os.makedirs(only_node) os.symlink(shutil.which('node'), os.path.join(only_node, 'node')) nopy = dict(placeholder, PATH=only_node) - r = subprocess.run([shutil.which('node'), RUNJS, 'direct.py'], input='{}', env=nopy, capture_output=True, text=True, timeout=60) + r = subprocess.run([shutil.which('node'), RUNJS, 'changes.py'], input='{}', env=nopy, capture_output=True, text=True, timeout=60) check('with no Python, a hook exits 0 and says why on stderr', r.returncode == 0 and 'Python' in r.stderr, f'rc={r.returncode} err={r.stderr[-300:]}')