diff --git a/gemini-extension.json b/gemini-extension.json index ebb25063d..4e8cef176 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -1,6 +1,6 @@ { "name": "axiomcode", - "version": "0.1.9", + "version": "0.1.10", "description": "Ask your repository how its code connects: who calls this, what breaks if I change it, which tests an edit reaches, how A reaches B. Answers come from a resolved call graph and are verified against it; nothing is guessed.", "contextFileName": "plugins/axiomcode/AGENTS.md", "mcpServers": { diff --git a/package.json b/package.json index 448fe5753..fbff87117 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@axiomcode/code-graph", - "version": "0.1.9", + "version": "0.1.10", "description": "AxiomCode Graph: a resolved call graph of your codebase grounded in formal methods, so you and your coding agents can see who calls what, what a change breaks, and which tests it reaches.", "repository": { "type": "git", @@ -31,11 +31,11 @@ ], "license": "FSL-1.1-Apache-2.0", "optionalDependencies": { - "@axiomcode/engine-darwin-arm64": "0.1.9", - "@axiomcode/engine-darwin-x64": "0.1.9", - "@axiomcode/engine-linux-x64": "0.1.9", - "@axiomcode/engine-linux-arm64": "0.1.9", - "@axiomcode/engine-win32-x64": "0.1.9" + "@axiomcode/engine-darwin-arm64": "0.1.10", + "@axiomcode/engine-darwin-x64": "0.1.10", + "@axiomcode/engine-linux-x64": "0.1.10", + "@axiomcode/engine-linux-arm64": "0.1.10", + "@axiomcode/engine-win32-x64": "0.1.10" }, "bin": { "axiomcode": "bin/axiomcode.js" diff --git a/parser/src/constants/typescript-constants.ts b/parser/src/constants/typescript-constants.ts index a37e94311..963e8f8a9 100644 --- a/parser/src/constants/typescript-constants.ts +++ b/parser/src/constants/typescript-constants.ts @@ -97,6 +97,14 @@ export const TS_SKIP_DIRECTORIES = [ '.next', '.nuxt', '.turbo', '.cache', '.yarn', 'bower_components', ] as const; +/** + * The entries of TS_SKIP_DIRECTORIES that hold a build's output. For the project under + * analysis they are a copy of its own source; for a DEPENDENCY they are where its + * declaration files live: a root `.d.ts` that re-exports `./dist/...`, or a package whose + * every declaration sits under `dist/types/`. A library walk keeps them. + */ +export const TS_BUILD_OUTPUT_DIRECTORIES = ['dist', 'build', 'out'] as const; + /** CSV file names for the TypeScript fact tables. */ export const TYPESCRIPT_CSV_FILES = { MODULES: 'all-typescript-modules.csv', diff --git a/parser/src/extract.ts b/parser/src/extract.ts index 78d2aa2e8..176a933dc 100644 --- a/parser/src/extract.ts +++ b/parser/src/extract.ts @@ -283,6 +283,16 @@ export async function extractProject(opts: ExtractOptions): Promise { const javaProjects = scanner.filterByLanguage(allProjects, ProjectLanguage.JAVA); const pythonProjects = scanner.filterByLanguage(allProjects, ProjectLanguage.PYTHON); const typescriptProjects = scanner.filterByLanguage(allProjects, ProjectLanguage.TYPESCRIPT); + // A DEPENDENCY whose declarations all sit in a build directory: detection looks for + // TypeScript near the top and skips build output, so such a package was staged as + // JavaScript only and every TypeScript call into it stayed unresolved. Its package.json + // says it has types; that makes the root a TypeScript project for a library walk. + if (opts.library === true && typescriptProjects.length === 0 && packageDeclaresTypes(absolutePath)) { + typescriptProjects.push({ + name: path.basename(absolutePath), path: absolutePath, + language: ProjectLanguage.TYPESCRIPT, hasSourceFiles: true, + }); + } const javascriptProjects = scanner.filterByLanguage(allProjects, ProjectLanguage.JAVASCRIPT); const csharpProjects = scanner.filterByLanguage(allProjects, ProjectLanguage.CSHARP); @@ -374,6 +384,7 @@ export async function extractProject(opts: ExtractOptions): Promise { ? ['node_modules', '.git', 'dist', 'build', 'out', 'coverage', 'test', 'tests', '__tests__', '.next', '.turbo'] : undefined, + libraryRoot: opts.library === true, })))), timed(Promise.all(pythonProjects.map((project, i) => pythonAnalyzer.analyze({ @@ -497,3 +508,24 @@ export async function extractProject(opts: ExtractOptions): Promise { console.log(`\n⏱️ TOTAL analysis time: ${elapsed}s`); console.log('✨ Analysis complete!\n'); } + +/** + * Does the package.json at `root` declare type definitions: a `types` / `typings` field, + * or a `types` condition anywhere in its `exports` map? + */ +function packageDeclaresTypes(root: string): boolean { + let manifest: unknown; + try { + manifest = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); + } catch { + return false; + } + if (typeof manifest !== 'object' || manifest === null) return false; + const m = manifest as Record; + if (typeof m.types === 'string' || typeof m.typings === 'string') return true; + const hasTypesCondition = (node: unknown): boolean => + typeof node === 'object' && node !== null + && Object.entries(node as Record).some(([key, value]) => + key === 'types' ? typeof value === 'string' : hasTypesCondition(value)); + return hasTypesCondition(m.exports); +} diff --git a/parser/src/test/discovery-tests.ts b/parser/src/test/discovery-tests.ts index af8ea224b..c17babfff 100644 --- a/parser/src/test/discovery-tests.ts +++ b/parser/src/test/discovery-tests.ts @@ -350,6 +350,76 @@ const CHECKS: Check[] = [ return null; }, }, + { + name: 'a-library-keeps-the-declarations-under-its-build-directory', + proves: 'staged with --library, a package whose root .d.ts re-exports `./dist/...` has the ' + + 'declarations under dist/ in its TypeScript modules; the same tree analysed as the project ' + + 'still skips dist/ (control)', + rulesOut: 'walking a dependency with the project skip list: dist/ was skipped, the root .d.ts ' + + 'pointed at nothing, and every call into the package stayed ambiguous_unknown', + run: async (tmp) => { + const files = { + 'package.json': '{"name":"reexporter","version":"1.0.0","types":"index.d.ts"}', + 'index.d.ts': "export * from './dist/api';\n", + 'dist/api.d.ts': 'export declare function connect(url: string): void;\n', + }; + const modulesOf = async (name: string, library: boolean): Promise => { + const root = build(tmp, name, files); + const out = path.join(tmp, `${name}-out`); + const silence = console.log; + console.log = () => {}; + try { + await extractProject({ projectPath: root, versionLink: 'v1', outputDir: out, layout: 'per-language', library }); + } finally { + console.log = silence; + } + const csv = path.join(out, 'typescript', 'all-typescript-modules.csv'); + return fs.existsSync(csv) ? fs.readFileSync(csv, 'utf-8') : ''; + }; + const asLibrary = await modulesOf('reexport-lib', true); + if (!asLibrary.includes('index.d.ts')) return 'control: the root index.d.ts is not analysed'; + if (!asLibrary.includes('dist/api.d.ts')) return 'as a library, dist/api.d.ts is not analysed'; + const asProject = await modulesOf('reexport-project', false); + if (asProject.includes('dist/api.d.ts')) return 'as the project, dist/ is analysed: a committed build copy would double the source'; + return null; + }, + }, + { + name: 'a-library-whose-declarations-all-sit-in-dist-is-typescript', + proves: 'staged with --library, a package with no declaration outside dist/ whose exports map ' + + 'names a `types` condition is analysed as TypeScript; a package that declares no types ' + + 'gets no typescript/ folder (control)', + rulesOut: 'detecting TypeScript only near the top of the tree: such a package was staged as ' + + 'JavaScript alone, so TypeScript calls into it had no declaration to resolve against', + run: async (tmp) => { + const run = async (name: string, files: Record): Promise => { + const root = build(tmp, name, files); + const out = path.join(tmp, `${name}-out`); + const silence = console.log; + console.log = () => {}; + try { + await extractProject({ projectPath: root, versionLink: 'v1', outputDir: out, layout: 'per-language', library: true }); + } finally { + console.log = silence; + } + const csv = path.join(out, 'typescript', 'all-typescript-modules.csv'); + return fs.existsSync(csv) ? fs.readFileSync(csv, 'utf-8') : ''; + }; + const typed = await run('dist-types-only', { + 'package.json': JSON.stringify({ name: 'typedpkg', version: '1.0.0', + exports: { './server': { types: './dist/types/server.d.ts', import: './dist/esm/server.js' } } }), + 'dist/types/server.d.ts': 'export declare function session(): string;\n', + 'dist/esm/server.js': 'export function session() { return ""; }\n', + }); + if (!typed.includes('dist/types/server.d.ts')) return 'a package declaring types only under dist/ has no TypeScript module'; + const untyped = await run('dist-js-only', { + 'package.json': '{"name":"plainpkg","version":"1.0.0","main":"dist/index.js"}', + 'dist/index.js': 'exports.go = function go() {};\n', + }); + if (untyped !== '') return 'control: a package that declares no types got TypeScript modules'; + return null; + }, + }, { name: 'per-language-keeps-the-javascript-of-a-dist-shipping-package', proves: 'extractProject in per-language layout writes javascript/ for a package whose only ' diff --git a/parser/src/workflows/typescript/typescript-project-analyzer.ts b/parser/src/workflows/typescript/typescript-project-analyzer.ts index 0ded7e17c..3c7642595 100644 --- a/parser/src/workflows/typescript/typescript-project-analyzer.ts +++ b/parser/src/workflows/typescript/typescript-project-analyzer.ts @@ -7,6 +7,7 @@ import * as ts from 'typescript'; import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; import { TS_SKIP_DIRECTORIES, + TS_BUILD_OUTPUT_DIRECTORIES, TS_SOURCE_EXTENSIONS, TYPESCRIPT_CSV_FILES, } from '@/constants/typescript-constants'; @@ -98,6 +99,13 @@ export interface TypeScriptAnalysisOptions { /** A pre-computed hash, for a caller that already has one. */ readonly serviceVersionLinkHash?: string; readonly excludeDirs?: readonly string[]; + /** + * The root is a DEPENDENCY staged with `--library`, not the project under analysis. + * Its build-output directories (TS_BUILD_OUTPUT_DIRECTORIES) are then walked: that is + * where a published package keeps its declarations, and skipping them left every call + * into such a package unresolved. Stated by the caller, as JavaScript's `libraryRoot` is. + */ + readonly libraryRoot?: boolean; } export interface TypeScriptAnalysisSummary { @@ -246,6 +254,9 @@ export class TypeScriptProjectAnalyzer { const rootDir = path.resolve(options.rootDir); const excludes = new Set(options.excludeDirs ?? TS_SKIP_DIRECTORIES); + if (options.libraryRoot === true) { + for (const name of TS_BUILD_OUTPUT_DIRECTORIES) excludes.delete(name); + } const configResolver = new TsConfigResolver(); // A PROGRAM is the unit of merge scope, so when the root declares one its // file list wins over a directory walk. This is not an optimisation: a diff --git a/plugins/axiomcode/.claude-plugin/plugin.json b/plugins/axiomcode/.claude-plugin/plugin.json index 1c32d1929..98067cc05 100644 --- a/plugins/axiomcode/.claude-plugin/plugin.json +++ b/plugins/axiomcode/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "axiomcode", "description": "Ask your repository how its code connects: who calls this, what breaks if I change it, which tests an edit reaches, how A reaches B. Answers come from a resolved call graph and are verified against it; nothing is guessed.", - "version": "0.1.9", + "version": "0.1.10", "author": { "name": "AxiomCode" } diff --git a/plugins/axiomcode/.codex-plugin/plugin.json b/plugins/axiomcode/.codex-plugin/plugin.json index 720690425..3940024ae 100644 --- a/plugins/axiomcode/.codex-plugin/plugin.json +++ b/plugins/axiomcode/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "axiomcode", - "version": "0.1.9", + "version": "0.1.10", "description": "Ask your repository how its code connects: who calls this, what breaks if I change it, which tests an edit reaches, how A reaches B. Answers come from a resolved call graph and are verified against it; nothing is guessed.", "author": { "name": "AxiomCode", "url": "https://github.com/AxiomCodeAI/axiomcodegraph" }, "homepage": "https://github.com/AxiomCodeAI/axiomcodegraph", diff --git a/plugins/axiomcode/.cursor-plugin/plugin.json b/plugins/axiomcode/.cursor-plugin/plugin.json index 3e3941cf7..ada8f9aab 100644 --- a/plugins/axiomcode/.cursor-plugin/plugin.json +++ b/plugins/axiomcode/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "axiomcode", - "version": "0.1.9", + "version": "0.1.10", "description": "Ask your repository how its code connects: who calls this, what breaks if I change it, which tests an edit reaches, how A reaches B. Answers come from a resolved call graph and are verified against it; nothing is guessed. Java, TypeScript, Python, JavaScript.", "author": { "name": "AxiomCode" }, "homepage": "https://github.com/AxiomCodeAI/axiomcodegraph", diff --git a/plugins/axiomcode/hooks/assist.py b/plugins/axiomcode/hooks/assist.py new file mode 100644 index 000000000..1660571c8 --- /dev/null +++ b/plugins/axiomcode/hooks/assist.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Stop: when the turn's work stood on an AxiomCode answer, say so under the reply (#1935). + +Agents finish with and without the graph, and they rarely credit the answer they acted on, so the user never sees what +it did. At the end of each turn this reads the session's transcript and prints, for each chain new in that turn, one +line the user can check (skills/axiomcode/scripts/ax_assists.py holds the rules): + + ◆ AxiomCode this turn · 1 answer led the agent's work + • Helped: `impact total` showed pricing.py (new to the agent) → it edited it → both picked test files (…) pass + +Rules it holds itself to: + · NOTHING FROM THE AGENT'S TEXT. The reply has already been shown when this runs; every link is read from the tool + calls and their results, and a link that was not checked is not printed. + · SILENT unless an answer led somewhere: no query, or a query nobody acted on, prints nothing. Only what helped is + counted; the answers that led nowhere are never shown. + · THE AGENT QUOTES IT. In Claude Code the receipt goes back to the agent once (decision "block"), which ends its + reply with it word for word, so it is part of the answer in every client (`-p`, the SDK, an IDE). The second stop + (stop_hook_active) checks the reply carries every line; when it does not, the receipt is shown as a message + instead. Cursor and Gemini cannot hand it back, so they get the message. +AXIOMCODE_ASSISTS=off turns it off. +""" +import json, os, subprocess, sys, time +sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'skills', 'axiomcode', 'scripts')) +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import _host + +if (os.environ.get('AXIOMCODE_ASSISTS') or '').lower() in ('off', '0', 'false', 'no'): + sys.exit(0) +try: + ev = _host.read() +except Exception: + sys.exit(0) +if _host.CURSOR: # Cursor's stop hook takes no message for the user + sys.exit(0) +tr = ev.get('transcript_path') or '' +if not tr or not os.path.isfile(tr): + sys.exit(0) +cwd = ev.get('cwd') or os.getcwd() +try: + r = subprocess.run(['git', '-C', cwd, 'rev-parse', '--show-toplevel'], capture_output=True, text=True, timeout=5) + root = r.stdout.strip() if r.returncode == 0 and r.stdout.strip() else cwd +except Exception: + root = cwd +ASK = ('AxiomCode checked the tool calls of this turn and recorded where its answers led your work. End your reply with ' + 'the block below, copied exactly as written (inside a ``` block), and nothing else: run no tools, add no claims ' + 'of your own about it.\n\n') + + +def _pending(root): + return os.path.join(root, '.axiomcode', 'assists.pending.json') + + +def _quoted(transcript, msg): + """whether the agent's text after the hand-back carries every line of the receipt""" + want = [l.strip() for l in msg.splitlines() if l.strip()] + for _ in range(6): # the host may still be writing the reply's last message + said, after = [], False + for line in open(transcript, encoding='utf-8', errors='replace'): + try: + e = json.loads(line) + except ValueError: + continue + c = (e.get('message') or {}).get('content') if isinstance(e, dict) else None + if e.get('type') == 'user' and ASK[:40] in json.dumps(c, ensure_ascii=False): + said, after = [], True + elif after and e.get('type') == 'assistant' and isinstance(c, list): + said += [x.get('text') or '' for x in c if isinstance(x, dict) and x.get('type') == 'text'] + text = '\n'.join(said) + if all(w in text for w in want): + return True + time.sleep(0.5) + return False + + +if ev.get('stop_hook_active'): + # the agent was handed the receipt; show it as a message only when its reply did not carry it + try: + pend = json.load(open(_pending(root))) + os.remove(_pending(root)) + except Exception: + sys.exit(0) + if pend.get('session') == str(ev.get('session_id') or '') and not _quoted(tr, pend['msg']): + print(json.dumps({'systemMessage': pend['msg']})) + sys.exit(0) +try: + import ax_assists + msg = ax_assists.at_stop(tr, os.path.realpath(root), str(ev.get('session_id') or '')) +except Exception: + sys.exit(0) # a receipt is never worth a failed turn +if not msg: + sys.exit(0) +if _host.GEMINI: + print(json.dumps({'systemMessage': msg})) + sys.exit(0) +try: + json.dump({'session': str(ev.get('session_id') or ''), 'msg': msg}, open(_pending(root), 'w')) + print(json.dumps({'decision': 'block', 'reason': ASK + msg})) +except Exception: + print(json.dumps({'systemMessage': msg})) diff --git a/plugins/axiomcode/hooks/hooks.json b/plugins/axiomcode/hooks/hooks.json index a97dd87f0..afe4941a9 100644 --- a/plugins/axiomcode/hooks/hooks.json +++ b/plugins/axiomcode/hooks/hooks.json @@ -72,6 +72,15 @@ "timeout": 5 } ] + }, + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/run.js\" assist.py", + "timeout": 15 + } + ] } ], "SessionStart": [ diff --git a/plugins/axiomcode/skills/axiomcode/SKILL.md b/plugins/axiomcode/skills/axiomcode/SKILL.md index 838be50f0..ebb9f763d 100644 --- a/plugins/axiomcode/skills/axiomcode/SKILL.md +++ b/plugins/axiomcode/skills/axiomcode/SKILL.md @@ -123,6 +123,22 @@ With them, a call into a dependency resolves to its declaration and a chain is t types (`client.post(...).json()`). Library bodies are not walked, so a dependency calling back into the project is not found this way. +## report + +At the end of a turn where an answer verifiably led your work (a file the answer showed first that you then read or +edited, or the tests it picked that you ran: Redirected / Helped / Useful), the plugin hands you a receipt once. End +your reply with it exactly as written. It is read from the session's tool calls, not from what you write, so there is +nothing to claim yourself; if the reply does not carry it, the user is shown it anyway. +Each repository keeps its own record (`.axiomcode/assists.jsonl` at its root) and its own page, +`.axiomcode/assists.html`, rebuilt whenever a turn adds to the record; `axiomcode report` rebuilds it by hand. + +## graph + +`axiomcode graph` draws the codebase as one page for the user to look at: directories, files, types and methods, with +the call edges between them and each edge's tier. It prints the page's path (`.axiomcode/graph/graph.html`); open it +in a browser. It is drawn from the graph the index built, rebuilt first only when a source file changed. It is not an +MCP tool: it answers no question, so when the user asks to see the graph, run it and give them the path. + ## What it cannot see — say so instead of guessing Reflection, string dispatch, event buses; receivers the engine could not type; callbacks invoked by a library; what a diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_assists.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_assists.py new file mode 100644 index 000000000..49bca6d16 --- /dev/null +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_assists.py @@ -0,0 +1,939 @@ +#!/usr/bin/env python3 +"""Where an AxiomCode answer verifiably helped the agent, read from the session record (#1935). + +Agents finish a task with and without AxiomCode, and they rarely say which answer changed what they did, so the user +never sees it. This reads the host's transcript (the agent's tool calls and their results, in order) and finds chains +an answer started: + + cause an AxiomCode answer (context / impact / path / tests) names a file + action the file was FIRST SEEN in that answer (no earlier prompt, tool call, tool result or reply named it), and + the agent then read it, edited it, or ran the tests the answer picked + effect the edit is still in the working tree, and the picked tests that ran passed + +Each chain gets the strongest label it qualifies for: + + Redirected Helped, and before the answer the agent was reading or editing other files in that turn, which it never + touched again and which are not in the final diff: the answer moved the work + Helped a file first seen in the answer was edited and the edit is kept, or the answer's picked tests ran and passed + Useful a file first seen in the answer was read (or its picked tests ran), and nothing was edited from it + +Nothing here comes from what the agent SAYS: its reply has already been shown when a Stop hook runs, so a claim in it +could not be checked before the user read it. Only links that were checked are printed; a chain is cut back to the +last link that holds, and an answer that led to nothing prints nothing. No counterfactual is stated ("grep would have +missed it"): only what happened in the session. + +Chains are appended to /.axiomcode/assists.jsonl, which only this module writes. A chain is printed once, and +again only when it grows a stronger label or a new edited file, so each turn shows what is new in that turn. + + ax_assists.py report [repo] every recorded chain as HTML (/.axiomcode/assists.html) + ax_assists.py replay [root] what each turn of a past session would have printed +""" +import html, json, os, re, subprocess, sys, time + +LEDGER = os.path.join('.axiomcode', 'assists.jsonl') +RANK = {'Useful': 1, 'Helped': 2, 'Redirected': 3} + +# an answer: the MCP tools (current and older names), or the CLI verbs run through a shell +MCP = re.compile(r'^mcp__.*axiomcode.*__(?:axiomcode_)?(context|impact|path|tests|test_impact|changed)$') +CLI = re.compile(r'(?:^|[\s;&|(/])axiomcode(?:-(context|impact|path|test-impact|changed)|\s+(context|impact|path|tests|test-impact|changed))\b([^;&|\n]*)') + +EXT = (r'py|pyi|ts|tsx|mts|cts|js|jsx|mjs|cjs|vue|svelte|astro|java|kt|kts|scala|groovy|cs|cshtml|razor|fs|go|rs|rb|' + r'php|swift|m|mm|c|cc|cpp|cxx|h|hpp|sql|proto|graphql|gql') +PATH = re.compile(r'(?\n]+)') # the command, then its file arguments +EDIT_TOOLS = {'Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'replace', 'write_file'} +READ_TOOLS = {'Read', 'read_file', 'NotebookRead'} +HARNESS = ('', '', '[SYSTEM NOTIFICATION', '"}: the text, with its line breaks""" + if t.startswith('{"result"'): + try: + v = json.loads(t).get('result') + if isinstance(v, str): + return v + except ValueError: + pass + return t + + +HOOK = 'axiomcode-hook' + + +def _graph_note(a): + """the hook event when this attachment is a graph answer the plugin's hooks gave the agent, else ''""" + if a.get('type') == 'hook_additional_context' or (a.get('type') == 'hook_success' + and a.get('hookEvent') == 'UserPromptSubmit'): + if _text(a.get('content')).lstrip().startswith('graph:'): + return str(a.get('hookEvent') or a.get('hookName') or '').split(':')[0] + return '' + + +def steps(transcript): + """the session in order: prompts, replies and tool calls with their results. Side chains (subagents) are left out: + what a subagent saw, the agent saw only as the subagent's reply.""" + out, calls = [], {} + try: + f = open(transcript, encoding='utf-8', errors='replace') + except OSError: + return out + with f: + for line in f: + try: + e = json.loads(line) + except ValueError: + continue + if not isinstance(e, dict) or e.get('isSidechain'): + continue + if e.get('type') in ('attachment', 'system'): + a = e.get('attachment') if isinstance(e.get('attachment'), dict) else {} + hook = _graph_note(a) + if hook: + # the plugin's own hooks put a graph answer in front of the agent: after an edit, the callers it + # reaches; on a prompt, where the task lands. That is an answer like a tool call's. + out.append({'kind': 'call', 'id': 'hook:' + str(e.get('uuid') or len(out)), 'name': HOOK, + 'input': {'event': hook}, 'result': _text(a.get('content')), 'error': False, + 'ts': e.get('timestamp') or ''}) + continue + if a.get('type') == 'hook_success' and 'graph:' in str(a.get('stdout') or '') and \ + str(a.get('hookEvent')) != 'UserPromptSubmit': + continue # the same text as the hook_additional_context that follows it + # a hook's context, a mentioned file, a reminder: the agent saw it, so what it names is not first seen later + t = json.dumps(e.get('attachment') or e.get('content') or '', ensure_ascii=False) + if PATH.search(t): + out.append({'kind': 'note', 'text': t, 'ts': e.get('timestamp') or ''}) + continue + if e.get('type') not in ('user', 'assistant'): + continue + msg = e.get('message') or {} + content = msg.get('content') + ts = e.get('timestamp') or '' + if e['type'] == 'user': + if isinstance(content, str) or (isinstance(content, list) and not any( + isinstance(x, dict) and x.get('type') == 'tool_result' for x in content)): + t = _text(content).strip() + if not t: + continue + real = not e.get('isMeta') and not t.startswith(HARNESS) + k = len(out) + while real and k and out[k - 1].get('name') == HOOK and out[k - 1]['input'].get('event') == 'UserPromptSubmit': + k -= 1 # the host records a prompt's own hook note before the prompt; it answers it + out.insert(k, {'kind': 'prompt' if real else 'note', 'text': t, 'ts': ts}) + continue + for x in content: + if isinstance(x, dict) and x.get('type') == 'tool_result': + s = calls.get(x.get('tool_use_id')) + if s is not None: + s['result'] = _unwrap(_text(x.get('content'))) + s['error'] = bool(x.get('is_error')) + s['extra'] = json.dumps(e.get('toolUseResult') or '', ensure_ascii=False) + continue + for x in content if isinstance(content, list) else []: + if not isinstance(x, dict): + continue + if x.get('type') == 'text' and x.get('text'): + out.append({'kind': 'reply', 'text': x['text'], 'ts': ts}) + elif x.get('type') == 'tool_use': + # mid: the calls of one message go out together, before any of their answers is seen + s = {'kind': 'call', 'id': x.get('id'), 'name': x.get('name') or '', 'input': x.get('input') or {}, + 'result': '', 'error': False, 'ts': ts, 'mid': msg.get('id')} + calls[s['id']] = s + out.append(s) + return out + + +# ---- paths ---------------------------------------------------------------------------------------------------------- + +def norm(p, root): + p = p.strip().strip('\'"`') + if not p: + return '' + if os.path.isabs(p): + rp = os.path.realpath(p) if os.path.exists(p) else os.path.normpath(p) + for r in {root, os.path.realpath(root)}: + if rp == r or rp.startswith(r.rstrip(os.sep) + os.sep): + return os.path.relpath(rp, r).replace(os.sep, '/') + return rp.replace(os.sep, '/') + return os.path.normpath(p).replace(os.sep, '/').lstrip('./') if not p.startswith('..') else os.path.normpath(p) + + +def paths_in(text, root): + return {n for n in (norm(m.group(1), root) for m in PATH.finditer(text or '')) if n} + + +def _seen_text(s): + if s['kind'] != 'call': + return s['text'] + return json.dumps(s['input'], ensure_ascii=False) + '\n' + (s['result'] or '') + '\n' + s.get('extra', '') + + +# a code name as a task writes it: snake_case, CamelCase, camelCase or Owner.member — not a plain word (find, where, define) +IDENT = re.compile(r'(? 0 + + +def answer_of(s): + """(verb, argument) when this step is an AxiomCode answer, else None""" + if s['kind'] != 'call' or s['error']: + return None + if s['name'] == HOOK: + return 'note', s['input'].get('event') or '' + m = MCP.match(s['name']) + if m: + args = ' '.join(str(v) for v in s['input'].values() if isinstance(v, str) and v.strip()) + return m.group(1).replace('_', '-'), args + if s['name'] in ('Bash', 'run_shell_command', 'Shell'): + m = CLI.search(str(s['input'].get('command') or '')) + if m: + verb = m.group(1) or m.group(2) + arg = re.sub(r'\s+(?:/|~)\S*$', '', (m.group(3) or '').strip()) # the trailing repository path + arg = re.sub(r'\s*\d?>&?\S*', '', arg).strip() + while arg.count(')') > arg.count('('): # `(time axiomcode impact X) 2>&1` + arg = arg[:arg.rindex(')')] + arg[arg.rindex(')') + 1:] + arg = arg.strip() + return verb, arg + return None + + +def _source(p, root): + """a source file of the repository, relative to it; '' for anything else (a tool's output file, a file elsewhere)""" + n = norm(p, root) + return n if n and not n.startswith(('/', '..')) and PATH.fullmatch(n) else '' + + +_Q = r'[\'"]([^\'"\n]+)[\'"]' +WRITES = [re.compile(r'open\(\s*' + _Q + r'\s*,\s*[\'"][wa]'), # open('f.py', 'w') + re.compile(r'Path\(\s*' + _Q + r'\s*\)\.write_(?:text|bytes)\('), # Path('f.py').write_text( + re.compile(r'(?:^|[^<>&\d])>>?\s*[\'"]?([^\s\'";&|)]+)'), # > f.py + re.compile(r'\btee\s+(?:-a\s+)?[\'"]?([^\s\'";&|]+)')] # tee f.py +VAR = re.compile(r'\b(\w+)\s*=\s*(?:Path\()?' + _Q) +SED_I = re.compile(r'\b(?:sed\s+(?:-[a-zE]*i\S*|--in-place)|perl\s+-[a-z]*i\w*)\s+([^;&|\n]+)') + + +def written(cmd, root): + """the source files a shell line writes: the target of open(…, 'w'), a redirection, tee or sed -i, by literal path or + through a variable the script assigned (p = 'f.py' … open(p, 'w')); a file the script only mentions is not written""" + out = set() + for r in WRITES: + out |= {m.group(1) for m in r.finditer(cmd)} + vars_ = {m.group(1): m.group(2) for m in VAR.finditer(cmd)} + for m in re.finditer(r'open\(\s*(\w+)\s*,\s*[\'"][wa]|\b(\w+)\.write_(?:text|bytes)\(', cmd): + v = m.group(1) or m.group(2) + if v in vars_: + out.add(vars_[v]) + for m in re.finditer(r'^([ \t]*)def\s+(\w+)\s*\(\s*(\w+)[^)]*\)\s*:(.*(?:\n\1[ \t]+.*)*)', cmd, re.M): + name, param, body = m.group(2), m.group(3), m.group(4) + if re.search(r'open\(\s*' + param + r'\s*,\s*[\'"][wa]|Path\(\s*' + param + r'\s*\)|\.write_(?:text|bytes)\(', body) and \ + re.search(r'\.write_(?:text|bytes)\(|open\([^)]*[\'"][wa]', body): + out |= {g.group(1) for g in re.finditer(r'(? index of the answer that first showed it + answers = {} # index -> (verb, arg) + turn_of = [] + turn = -1 + for i, s in enumerate(ss): + if s['kind'] == 'prompt': + turn += 1 + asked_for |= set(IDENT.findall(s['text'])) | {w for c in re.findall(r'`([^`\n]{1,80})`', s['text']) + for w in re.findall(r'[A-Za-z_]\w{2,}', c)} + turn_of.append(turn) + a = answer_of(s) + if a: + answers[i] = a + verb = a[0] + # what this call and the calls sent with it name, the agent named before it saw the answer + for f in paths_in(' '.join(json.dumps(x['input'], ensure_ascii=False) for x in _batch(ss, i)), root): + seen_full.add(f) + seen_base.add(f.rsplit('/', 1)[-1]) + res = s['result'] or '' + # the name asked about is on every caller's line by construction; the task naming it is no lead to its callers + lead = asked_for - {w for x in re.findall(r'[\w.]+', a[1]) for w in (x, *x.split('.'))} + led = set() + for ln in res.splitlines(): + if lead & {w for x in IDENT.findall(ln) for w in (x, *x.split('.'))}: + led |= {norm(g.group(1), root) for g in PATH.finditer(ln)} + for m in (ROW.finditer(s['result'] or '') if mixed(s) else PATH.finditer(s['result'] or '')): + after = (s['result'] or '')[m.end():m.end() + 2] + if not PLACE.search(m.group(0) + after): + continue # a bare file name (a listing, prose) is not a place the answer gave + if verb == 'note' and not re.match(r':\d', after): + continue # a note nobody asked for credits a place (file:line), not a file summary + f = norm(m.group(1), root) + if not f: + continue + if f in led: + continue # the task already named what a row on this file is about: its lead + b = f.rsplit('/', 1)[-1] + if f in seen_full or f in first or (not GENERIC.match(b) and b in seen_base): + continue + if any(_same(f, g) for g in seen_full): + continue + first[f] = i + for f in paths_in(_seen_text(s), root): + seen_full.add(f) + seen_base.add(f.rsplit('/', 1)[-1]) + + acts = [(i, k, fs) for i, s in enumerate(ss) for k, fs in touched(s, root)] + # j acted on the answer at i only when it was sent after i's answer came back, not alongside the call + sent_after = lambda j, i: j > i and not (ss[i].get('mid') and ss[j].get('mid') == ss[i].get('mid')) + edits_at = {j for j, k, fs in acts if k == 'edit'} + ids = lambda js: [ss[j]['id'] for j in js] + out = [] + def picked(i): + # the tests an answer picked: every test file a test verb names (picking them is its job); for the other verbs + # only a test file first seen in it, since a test the agent already knew of was not picked by the answer + verb = answers[i][0] + t = {f for f in paths_in(ss[i]['result'], root) + if TEST_FILE.search(f) and (verb in ('tests', 'test-impact') or first.get(f) == i)} + # one file named twice (`test_x.py` in a run line, `tests/test_x.py` in a row) is one test file + return {f for f in t if not any(g != f and g.endswith('/' + f) for g in t)} + picks = {i: picked(i) for i in answers} + # (test run, test file) -> the answer it is credited to: the latest one that picked the file before the run + owner = {} + for j, k, fs in acts: + if k == 'test': + for i in sorted(answers): + if sent_after(j, i): + for t in picks[i]: + if _hit(t, fs): + owner[(j, t)] = i + for i, (verb, arg) in sorted(answers.items()): + shown = sorted(f for f, j in first.items() if j == i) + tests_named = picks[i] + named = paths_in(ss[i]['result'], root) + read, edited, ev_read, ev_edit = [], [], [], [] + for f in shown: + r = [j for j, k, fs in acts if k == 'read' and sent_after(j, i) and _hit(f, fs)] + e = [j for j, k, fs in acts if k == 'edit' and sent_after(j, i) and _hit(f, fs)] + kept = diff is None or _hit(f, diff) + if e and kept: + edited.append(f); ev_edit += e; ev_read += r + elif r or e: + read.append(f); ev_read += r or e + # the answer's picked tests: a later test command that names one of them + ran, passed, ev_test = set(), set(), [] + for j, k, fs in acts: + if k == 'test' and sent_after(j, i): + hit = {t for t in tests_named if _hit(t, fs) and owner.get((j, t)) == i} + if hit: + ev_test.append(j) + ran |= hit + said = ss[j]['result'] or '' + if not ss[j]['error'] and PASSED.search(said) and not FAILED.search(said) and not BROKE.search(said): + passed |= hit + else: + passed -= hit # a test file passes when its latest run does + passed_all = bool(ran) and ran == passed + if edited or (ran and passed_all and verb in ('tests', 'test-impact')): + label = 'Helped' + elif read or ran: + label = 'Useful' + else: + continue + frm, ev_from = [], [] + if edited: + # the search the answer ended: files read or edited since the prompt or the last edit before the answer, + # never touched again and not in the final diff + start = i + while start > 0 and ss[start - 1]['kind'] != 'prompt' and (start - 1) not in edits_at: + start -= 1 + before, hits = {}, {} + for j, k, fs in (a for a in acts if start <= a[0] < i): + if k in ('read', 'edit'): + for f in fs: + before.setdefault(f, j) + hits[f] = hits.get(f, 0) + 1 + later = set() + for s2 in ss[i + 1:]: + if s2['kind'] == 'call': + later |= paths_in(json.dumps(s2['input'], ensure_ascii=False), root) + frm = sorted(p for p in before if not _hit(p, shown) and not _hit(p, later) + and (diff is None or not _hit(p, diff)) and not _hit(p, named)) + ev_from = sorted({before[p] for p in frm}) + # a redirect is work that was going somewhere else: two or more files dropped, or one gone back to, and the + # change landing in code (updating the tests an answer listed is help, not a change of direction) + if frm and (len(frm) > 1 or hits[frm[0]] > 1) and any(not TEST_FILE.search(f) for f in edited): + label = 'Redirected' + else: + frm, ev_from = [], [] + out.append({'answer': ss[i]['id'], 'turn': turn_of[i], 'ts': ss[i]['ts'], 'verb': verb, 'arg': arg[:160], + 'label': label, 'shown': len(shown), 'edited': edited, 'read': read, 'from': frm, + 'tests_ran': len(ran), 'tests_passed': len(passed), 'tests': sorted(ran), + 'tests_failed': sorted(ran - passed), + # the tool calls each link stands on, by the host's id, so every chain can be checked by hand + 'evidence': {'answer': ss[i]['id'], 'from': ids(ev_from), 'read': ids(sorted(set(ev_read))), + 'edit': ids(sorted(set(ev_edit))), 'test': ids(ev_test)}, + # the same links as the session shows them, in order, for a reader to inspect + 'trail': _trail(ss, root, i, edited + read, ev_from, ev_read, ev_edit, ev_test)}) + return out + + +def _trail(ss, root, i, files, ev_from, ev_read, ev_edit, ev_test): + """[{n, kind, text, out}]: the answer (with its rows on the credited files) and each later call a link stands on""" + def brief(s, kind=''): + inp = s['input'] + if kind == 'edit' and s['name'] not in EDIT_TOOLS: + w = written(str(inp.get('command') or ''), root) + return 'shell edit of ' + ', '.join(sorted(w)) if w else 'shell edit' + if s['name'] in EDIT_TOOLS or s['name'] in READ_TOOLS: + return f"{s['name']} {_source(inp.get('file_path') or inp.get('path') or '', root)}" + c = str(inp.get('command') or json.dumps(inp, ensure_ascii=False)) + return re.sub(r'^cd \S+\s*(?:&&|;)\s*', '', c).strip().splitlines()[0][:160] if c.strip() else s['name'] + res = ss[i]['result'] or '' + rows = [l.rstrip() for l in res.splitlines() if any(f.rsplit('/', 1)[-1] in l for f in files) or + (l.lstrip()[:4] == 'run:')][:8] + v, a = answer_of(ss[i]) or ('', '') + out = [{'n': i, 'kind': 'answer', 'text': _q({'verb': v, 'arg': a}).strip('`'), + 'out': '\n'.join(rows)}] + for kind, js in (('before', ev_from), ('read', ev_read), ('edit', ev_edit), ('test', ev_test)): + for j in sorted(set(js)): + tail = '' + if kind == 'test': + m = re.findall(r'(?:Tests?:|Test Files|=+)?[^\n]*\b\d+ (?:passed|failed)[^\n]*', ss[j]['result'] or '') + tail = m[-1].strip(' =')[:160] if m else ('passed' if not ss[j]['error'] else 'failed') + out.append({'n': j, 'kind': kind, 'text': brief(ss[j], kind), 'out': tail}) + return sorted(out, key=lambda x: x['n']) + + +def working_diff(root): + try: + r = subprocess.run(['git', '-C', root, 'status', '--porcelain', '--untracked-files=all'], + capture_output=True, text=True, timeout=10) + except Exception: + return None + if r.returncode: + return None + return {norm(l[3:].split(' -> ')[-1], root) for l in r.stdout.splitlines() if len(l) > 3} + + +# ---- what is printed ------------------------------------------------------------------------------------------------ + +NOTES = {'PostToolUse': "AxiomCode's after-edit check", 'UserPromptSubmit': "AxiomCode's note on the task"} + + +def _q(c): + if c['verb'] == 'note': + return NOTES.get(c['arg'], 'an AxiomCode note') + # what was asked, without flags or paths (--json, an output file), and a shell loop's variable says nothing + arg = ' '.join(w for w in c['arg'].split() if not w.startswith(('-', '/', '$', '~'))) + v = c['verb'] + (f" {arg}" if arg else '') + return f'`{v[:70]}…`' if len(v) > 70 else f'`{v}`' + + +def _names(fs, n=3): + b = [f.rsplit('/', 1)[-1] for f in fs] + return ', '.join(b[:n]) + (f' +{len(b) - n}' if len(b) > n else '') + + +def _tests(c): + """the effect of the tests an answer picked: which ran, and whether they pass""" + n, t = c['tests_ran'], c.get('tests') or [] + if not n: + return '' + names = f" ({_names(t)})" if t else '' + if c['tests_passed'] == n: + return f" → {({1: 'its', 2: 'both'}.get(n, f'all {n}'))} picked test file{'s' if n > 1 else ''}{names} {'passes' if n == 1 else 'pass'}" + ok = [f for f in t if f not in (c.get('tests_failed') or [])] + return f" → picked test file{'s' if len(ok) > 1 else ''} {_names(ok)} {'pass' if len(ok) > 1 else 'passes'}" if ok else "" + + +def line(c): + """one chain as cause → action → effect, naming the files and tests it stands on""" + q, ed, rd = _q(c), c['edited'], c['read'] + them = lambda fs: 'it' if len(fs) == 1 else 'them' + if c['label'] == 'Redirected': + return (f"Redirected: the agent was in {_names(c['from'])} → {q} pointed it to {_names(ed)} directly " + f"→ it left those and edited {them(ed)}{_tests(c)}") + if ed: + extra = f", read {_names(rd)}" if rd else '' + return f"{c['label']}: {q} pointed the agent to {_names(ed)} directly, before it had come across {them(ed)} → it edited {them(ed)}{extra}{_tests(c)}" + if rd: + return f"{c['label']}: {q} pointed the agent to {_names(rd)} directly, before it had come across {them(rd)} → it read {them(rd)}{_tests(c)}" + n = c['tests_ran'] + ok = 'all pass' if c['tests_passed'] == n else '' + return (f"{c['label']}: {q} picked {n} test file{'s' if n > 1 else ''} ({_names(c.get('tests') or [])}) " + f"→ the agent ran {'it' if n == 1 else 'them'}" + (f" → {'it passes' if n == 1 else ok}" if ok else '')) + + +def asked(ss): + """the answers the session holds: [(turn, step id)]""" + out, turn = [], -1 + for s in ss: + if s['kind'] == 'prompt': + turn += 1 + elif answer_of(s): + out.append((turn, s['id'])) + return out + + +def _load(root): + p = os.path.join(root, LEDGER) + out = [] + try: + with open(p, encoding='utf-8') as f: + for l in f: + try: + r = json.loads(l) + except ValueError: + continue + if 'answer' in r: + out.append(r) + except OSError: + pass + return out + + +def new_chains(found, recorded, session): + """the chains not printed before, or grown since: a stronger label, or a file edited that was not""" + best = {} + for r in recorded: + if r.get('session') == session and 'answer' in r: + best[r['answer']] = r + out = [] + for c in found: + old = best.get(c['answer']) + if old and RANK[c['label']] <= RANK.get(old.get('label'), 0) and set(c['edited']) <= set(old.get('edited') or []): + continue + out.append(c) + return out + + +def receipt(fresh, totals=None, turn=None, session=None): + """`turn` / `session`: (answers asked, answers that led to work), for the count lines""" + if not fresh: + return '' + fresh = sorted(fresh, key=lambda c: -RANK[c['label']]) + said = [] + for c in fresh: + l = line(c) + if l not in said: + said.append(l) + head = '◆ AxiomCode this turn' + now = turn[1] if turn else 0 + # an answer from an earlier turn the agent acted on only now (it read a file then, and edits it in this turn) + earlier = len({c['answer'] for c in fresh} - set(turn[2] if turn and len(turn) > 2 else [])) if turn else 0 + if now and earlier: + head += f" · the agent acted on {now} answer{'s' if now > 1 else ''} from this turn and {earlier} from an earlier turn" + elif now: + head += f" · the agent acted on {now} answer{'s' if now > 1 else ''}" + elif earlier: + head += f" · the agent acted on {earlier} answer{'s' if earlier > 1 else ''} from an earlier turn" + lines = [head] + [f' • {l}' for l in said[:5]] + if len(said) > 5: + lines.append(f' • and {len(said) - 5} more (axiomcode report)') + if totals and session and session[1] > len({c['answer'] for c in fresh}): + t = totals + lines.append(f" session: the agent acted on {session[1]} answer{'s' if session[1] > 1 else ''} · " + ', '.join( + f'{t[k]} {k}' for k in ('Redirected', 'Helped', 'Useful') if t.get(k)) + + (f" · {t['_edited']} file{'s' if t['_edited'] > 1 else ''} edited from them" if t.get('_edited') else '') + + (f" · {t['_tests']} picked test file{'s' if t['_tests'] > 1 else ''} run" if t.get('_tests') else '')) + return '\n'.join(lines) + + +def record(root, session, fresh): + p = os.path.join(root, LEDGER) + try: + os.makedirs(os.path.dirname(p), exist_ok=True) + with open(p, 'a', encoding='utf-8') as f: + for c in fresh: + f.write(json.dumps(dict(c, session=session, repo=os.path.basename(root.rstrip('/')), + at=time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()))) + '\n') + except OSError: + pass + + +def _best(recorded, session=None): + """each answer's strongest recorded chain""" + best = {} + for r in recorded: + if 'answer' in r and (session is None or r.get('session') == session): + k = (r.get('session'), r['answer']) + if k not in best or RANK.get(r['label'], 0) >= RANK.get(best[k]['label'], 0): + best[k] = r + return best + + +def totals(recorded, session): + """labels per answer, plus `_edited` (files edited from an answer) and `_tests` (picked test files run)""" + t, edited, tests = {}, set(), set() + for r in _best(recorded, session).values(): + t[r['label']] = t.get(r['label'], 0) + 1 + for r in recorded: + if r.get('session') == session and 'answer' in r: + edited |= set(r.get('edited') or []) + tests |= set(r.get('tests') or []) + if edited: + t['_edited'] = len(edited) + if tests: + t['_tests'] = len(tests) + return t + + +def at_stop(transcript, root, session): + """the receipt for the turn that just ended, recorded; '' when there is nothing new""" + ss = steps(transcript) + found = chains(ss, root, working_diff(root)) + recorded = _load(root) + fresh = new_chains(found, recorded, session) + q = asked(ss) + last = sum(1 for s in ss if s['kind'] == 'prompt') - 1 + record(root, session, fresh) + if not fresh: + return '' + led = {c['answer'] for c in found} + now = [i for t, i in q if t == last] + msg = receipt(fresh, totals(recorded + [dict(c, session=session) for c in fresh], session), + turn=(len(now), len(led & set(now)), now), session=(len(q), len(led))) + try: + # the page is rebuilt whenever the ledger grows, so it is current without anyone asking for it + page, _ = report(root) + msg += f'\n report: file://{page}' + except Exception: + pass + return msg + + +# ---- the report ----------------------------------------------------------------------------------------------------- + +CSS = """ +:root{--bg:#fafaf8;--fg:#1b1b19;--mut:#66655f;--card:#fff;--line:#e5e3dd;--soft:#f2f1ec;--acc:#2f6fed;--ok:#127a55;--r:#7a3df0;--u:#8a6d1f} +@media (prefers-color-scheme:dark){:root:not([data-theme=light]){--bg:#121211;--fg:#ecebe6;--mut:#a3a29c;--card:#1b1b1a;--line:#302f2c;--soft:#22221f;--acc:#7aa5ff;--ok:#4fd1a1;--r:#b394ff;--u:#e0c063}} +:root[data-theme=dark]{--bg:#121211;--fg:#ecebe6;--mut:#a3a29c;--card:#1b1b1a;--line:#302f2c;--soft:#22221f;--acc:#7aa5ff;--ok:#4fd1a1;--r:#b394ff;--u:#e0c063} +*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.55 -apple-system,system-ui,sans-serif} +main{max-width:940px;margin:0 auto;padding:40px 16px 80px} +code,pre{font:12.5px/1.5 ui-monospace,Menlo,monospace}pre{white-space:pre-wrap;word-break:break-word;margin:6px 0 0;padding:8px 10px;background:var(--soft);border-radius:6px} +.eyebrow{font-size:12px;letter-spacing:.12em;text-transform:uppercase;color:var(--acc);font-weight:700} +h1{font-size:34px;line-height:1.15;margin:8px 0 12px;letter-spacing:-.01em}h2{font-size:21px;margin:0 0 4px} +.sec{margin-top:56px}.num{color:var(--mut);font-size:13px;font-weight:600;letter-spacing:.06em} +.lede{font-size:18px;color:var(--fg);max-width:720px;margin:0 0 24px}.sub{color:var(--mut);margin:0 0 18px} +.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:12px} +.tile{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:16px}.tile b{display:block;font-size:34px;line-height:1.1} +.tile span{color:var(--mut);font-size:13.5px}.verified{margin-top:14px;font-size:13.5px;color:var(--ok);font-weight:600} +.cap{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:16px}.cap b{font-size:26px;color:var(--acc)} +.cap h3{margin:4px 0 6px;font-size:16px}.cap p{margin:0;color:var(--mut);font-size:14px} +.case{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:20px;margin:14px 0} +.case .top{display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap;align-items:baseline} +.case .repo{font-size:18px;font-weight:700}.case .q{color:var(--mut);font-size:13.5px} +.badge{font-size:12px;font-weight:700;padding:3px 9px;border-radius:99px;background:var(--soft);color:var(--ok);white-space:nowrap} +.tl{list-style:none;margin:14px 0 0;padding:0;counter-reset:s}.tl li{position:relative;padding:0 0 14px 34px;counter-increment:s} +.tl li:before{content:counter(s);position:absolute;left:0;top:0;width:22px;height:22px;border-radius:50%;background:var(--acc);color:#fff;font-size:12px;font-weight:700;display:flex;align-items:center;justify-content:center} +.tl li:after{content:"";position:absolute;left:10.5px;top:24px;bottom:2px;width:1px;background:var(--line)}.tl li:last-child:after{display:none} +.tl .t{font-weight:600}.tl .d{color:var(--mut);font-size:13.5px}.files{margin:4px 0 0;padding:0;list-style:none}.files li{padding:0!important;counter-increment:none}.files li:before,.files li:after{display:none} +.pass{color:var(--ok);font-weight:600;font-size:13px}.outcome{margin-top:6px;padding:12px 14px;border-left:3px solid var(--ok);background:var(--soft);border-radius:0 8px 8px 0;font-size:14.5px} +details{background:var(--card);border:1px solid var(--line);border-radius:10px;margin:8px 0}summary{cursor:pointer;padding:12px 14px;list-style:none} +summary::-webkit-details-marker{display:none}summary .l{font-size:12px;font-weight:700;margin-right:8px}.Redirected{color:var(--r)}.Helped{color:var(--ok)}.Useful{color:var(--u)} +details .body{padding:0 14px 14px}.ev{border-top:1px solid var(--line);padding:8px 0}.ev .k{display:inline-block;min-width:74px;font-size:12px;font-weight:700;color:var(--mut);text-transform:uppercase} +.ev .n{color:var(--mut);font-size:12px;margin-right:6px}.filters{display:flex;gap:8px;flex-wrap:wrap;margin:10px 0} +.filters button{font:inherit;font-size:13px;padding:5px 12px;border-radius:99px;border:1px solid var(--line);background:var(--card);color:var(--fg);cursor:pointer} +.filters button.on{background:var(--acc);border-color:var(--acc);color:#fff}.method{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:18px 20px} +.method h3{font-size:15px;margin:14px 0 4px}.method h3:first-child{margin-top:0}.method ul{margin:4px 0;padding-left:20px}.method p{margin:4px 0} +@media (max-width:560px){h1{font-size:27px}.tile b{font-size:28px}} +""" + +KIND = {'answer': 'asked', 'before': 'before', 'read': 'read', 'edit': 'edited', 'test': 'ran tests'} + + +def _caps(r): + """which of the three ways a chain shows: found files first, connected a change across files, tests run""" + c = set() + if r.get('edited') or r.get('read'): + c.add('found') + if r['verb'] in ('impact', 'path') and len((r.get('edited') or []) + (r.get('read') or [])) > 1 or \ + (r['verb'] == 'note' and r.get('arg') == 'PostToolUse') or r.get('from'): + c.add('connected') + if r.get('tests_passed'): + c.add('tests') + return c + + +def _score(r): + return 4 * len(r.get('edited') or []) + len(r.get('read') or []) + min(r.get('tests_passed', 0), 3) + \ + (6 if r['label'] == 'Redirected' else 0) + + +def _story(r): + """the featured timeline: what was asked, what the answer named first, what the agent then did, and what came of it""" + e = html.escape + ed, rd, n = r.get('edited') or [], r.get('read') or [], len(r.get('edited') or []) + len(r.get('read') or []) + fl = lambda fs: '
    ' + ''.join(f'
  • {e(f)}
  • ' for f in fs) + '
' + st = [] + if r.get('from'): + st.append(f"
  • The agent was working in other files
    {fl(r['from'])}
  • ") + st.append(f"
  • The agent queried AxiomCode
    {e(_q(r).strip('`'))}
  • ") + if n: + word = {1: 'one', 2: 'two', 3: 'three', 4: 'four', 5: 'five'}.get(n, str(n)) + st.append(f"
  • AxiomCode identified {word} file{'s' if n > 1 else ''} the agent had not yet " + f"come across
    {fl(ed + rd)}
  • ") + if ed: + whole = len(ed) == n + st.append(f"
  • The agent edited {('all ' + str(len(ed)) + ' of them' if len(ed) > 2 else ('both' if len(ed) == 2 else 'it')) if whole else _names(ed)}" + f"{' and read ' + _names(rd) if rd else ''}
    The edits remained in the working tree.
  • ") + elif rd: + st.append(f"
  • The agent read {'them' if len(rd) > 1 else 'it'}
  • ") + if r.get('tests_ran'): + ok = set(r.get('tests') or []) - set(r.get('tests_failed') or []) + st.append("
  • The agent ran the tests AxiomCode selected
      " + ''.join( + f"
    • {e(f.rsplit('/', 1)[-1])} passed
    • " for f in sorted(ok)) + '
  • ') + out = [] + if n: + out.append(f"AxiomCode identified {n} file{'s' if n > 1 else ''} before the agent encountered " + f"{'them' if n > 1 else 'it'}") + if ed: + out.append(f"the agent edited {('all ' + str(len(ed)) if len(ed) > 2 else ('both' if len(ed) == 2 else 'it')) if len(ed) == n else _names(ed)}" + + (f" and read {_names(rd)}" if rd else '')) + elif rd: + out.append('the agent read ' + ('them' if len(rd) > 1 else 'it')) + if r.get('tests_passed'): + k = r['tests_passed'] + out.append(f"then ran {k} selected test file{'s' if k > 1 else ''} successfully") + txt = ', '.join(out) + return '
      ' + ''.join(st) + '
    ' + (f"
    Observed outcome. {e(txt[0].upper() + txt[1:])}.
    " if txt else '') + + +def _evidence(r): + """every link as the session record shows it, by sequence number""" + e = html.escape + out = [] + for x in r.get('trail') or []: + o = f"
    {e(x['out'])}
    " if x.get('out') else '' + out.append(f"
    #{x['n']}{KIND.get(x['kind'], x['kind'])}" + f"{e(x['text'])}{o}
    ") + return ''.join(out) or '
    Evidence ids: ' + e(json.dumps(r.get('evidence'))) + '
    ' + + +def report(root): + rows = sorted(_best(_load(root)).values(), key=lambda r: r.get('at') or '', reverse=True) + e = html.escape + sessions = {} + for r in rows: + sessions.setdefault(r.get('session') or '?', []).append(r) + edited = {(r.get('session'), f) for r in _load(root) for f in r.get('edited') or []} + tests = {(r.get('session'), f) for r in _load(root) for f in r.get('tests') or [] if f not in (r.get('tests_failed') or [])} + ns, ne, nt = len(sessions), len({f for _, f in edited}), len({f for _, f in tests}) + caps = {k: [r for r in rows if k in _caps(r)] for k in ('found', 'connected', 'tests')} + pl = lambda n, w: f"{n} {w}{'s' if n != 1 else ''}" + body = [f"
    01
    AxiomCode · agent impact report

    From code understanding to verified agent actions.

    " + f"

    Across {pl(ns, 'coding session')}, AxiomCode identified files agents had not yet explored, " + f"guided the edits that followed, and selected tests the agents ran and passed.

    "] + for v, k in ((ns, 'Coding sessions'), (len(rows), 'Answers the agent acted on'), (ne, 'Previously unseen files edited'), + (nt, 'Selected test files passing')): + if v: + body.append(f"
    {v}{k}
    ") + body.append("
    ✓ Verified against recorded agent actions, not agent self-reports.
    ") + + body.append("
    02

    How AxiomCode influenced agent behavior

    " + "

    Each answer below is counted under every way it showed.

    ") + for k, t, d in (('found', 'Found relevant files before the agent discovered them', + 'AxiomCode named code locations the agent had not yet encountered; the agent then inspected or edited them.'), + ('connected', 'Connected changes across the codebase', + 'Impact and path answers, and the check after each edit, named related implementation files and tests, ' + 'moving the agent beyond where it started.'), + ('tests', 'Turned analysis into test execution', + 'Agents ran the tests AxiomCode selected, and those runs passed.')): + if caps[k]: + body.append(f"
    {len(caps[k])}

    {t}

    {d}

    ") + body.append('
    ') + + feat, seen = [], set() + for r in sorted(rows, key=_score, reverse=True): + if r.get('session') in seen or not (r.get('edited') or r.get('read')): + continue + feat.append(r); seen.add(r.get('session')) + if len(feat) == 3: + break + if feat: + body.append("
    03

    Featured evidence

    " + "

    The strongest chains, each read step by step from the session record.

    ") + for r in feat: + badge = 'Observed edit + tests passed' if r.get('edited') and r.get('tests_passed') else ( + 'Observed edit' if r.get('edited') else 'Observed read') + body.append(f"
    {e(r.get('repo') or '')}
    " + f"
    {e({'impact': 'Impact analysis', 'path': 'Call path', 'context': 'Code context', 'tests': 'Test selection', 'note': 'Graph note'}.get(r['verb'], r['verb']))}" + f" · {e(_q(r).strip('`'))}
    {badge}
    {_story(r)}" + f"
    Show the recorded actions
    {_evidence(r)}
    ") + body.append('
    ') + + body.append("
    04

    All verified sessions

    " + "

    Open any answer to see the query, AxiomCode's rows, and each later action with its sequence " + "number in the session.

    " + "" + "
    ") + for s, rs in sessions.items(): + for r in rs: + tags = ' '.join(t for t, ok in (('edited', r.get('edited')), ('read', r.get('read')), ('tests', r.get('tests_passed'))) if ok) + body.append(f"
    {r['label']}" + f"{e(r.get('repo') or s[:8])} · " + f"{re.sub(r'`([^`]+)`', r'\1', e(line(r).split(': ', 1)[1]))}" + f"
    {_evidence(r)}
    ") + body.append('
    ') + + body.append("""
    05

    Measured from agent actions, not agent claims

    +

    An answer is credited only when all of these hold in the session record

      +
    • The AxiomCode answer named a file (as a place: a path or file:line) before any earlier prompt, tool call, +tool output or reply had named it.
    • +
    • The task had not already named what that row is about; a function the issue names is the task's lead, not AxiomCode's.
    • +
    • Afterwards the agent read or edited that file, or ran a test file the answer selected.
    • +
    • Edits are confirmed in the working tree when the turn ends; test runs and their results come from the tool output.
    +

    Labels are assigned mechanically

      +
    • Helped: a file the answer named first was edited and the edit was kept, or the tests a test-selection +answer picked ran and passed.
    • +
    • Useful: a file the answer named first was read, or tests it picked were run, without an edit from it.
    • +
    • Redirected: Helped, and before the answer the agent had been reading or editing two or more other files +that it never returned to and did not change.
    +

    Scope

    This report shows that AxiomCode's answers were followed by these agent actions. It does not measure what the +agent would have done without them, or whether the edits were correct; a passing test supports only that the selected test +run passed.

    """) + js = ("") + page = (f"" + f"Agent Impact Report
    {''.join(body)}
    {js}") + out = os.path.join(root, '.axiomcode', 'assists.html') + os.makedirs(os.path.dirname(out), exist_ok=True) + with open(out, 'w', encoding='utf-8') as f: + f.write(page) + return out, len(rows) + + +def replay(transcript, root): + """what each turn of a past session would have printed (the working tree then is not known: an edit counts as kept)""" + ss = steps(transcript) + ends = [i for i, s in enumerate(ss) if s['kind'] == 'prompt'][1:] + [len(ss)] + recorded, n = [], 0 + for e in ends: + n += 1 + fresh = new_chains(chains(ss[:e], root, None), recorded, 'replay') + recorded += [dict(c, session='replay') for c in fresh] + q = asked(ss[:e]) + led = {c['answer'] for c in recorded} + now = [i for t, i in q if t == n - 1] + r = receipt(fresh, totals(recorded, 'replay'), turn=(len(now), len(led & set(now)), now), session=(len(q), len(led))) + if r: + print(f'--- turn {n}\n{r}') + return recorded + + +if __name__ == '__main__': + a = sys.argv[1:] + if a[:1] == ['report']: + p, n = report(os.path.abspath(a[1] if len(a) > 1 else '.')) + print(f'{n} chain(s) → {p}') + elif a[:1] == ['replay'] and len(a) > 1: + rec = replay(a[1], os.path.abspath(a[2] if len(a) > 2 else '.')) + t = {} + for r in rec: + t[r['label']] = t.get(r['label'], 0) + 1 + print('total:', json.dumps(t)) + else: + print(__doc__.strip().splitlines()[-2:][0]); print(__doc__.strip().splitlines()[-1]); sys.exit(2) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py index 86ce35f8d..ad51a0e54 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py @@ -185,7 +185,8 @@ def is_lead(p): # run: stays LAST: the answer ends with the command to run, whatever else the foot carries. kept = [x for x in foot if x.startswith(('verified', 'bound:'))][:3] out += kept + unknown(doc, repo) + [x for x in foot if x.startswith('run:')][:1] - return out + # what the question resolved to goes first (ax_grep.head): the places are a page of the answer, not all of it + return ax_grep.head({'tests': 'test-impact'}.get(verb, verb), doc) + out UNKNOWN_SHOWN = 5 # unresolved sites listed under an answer: the nearest; the verbs' --json carries up to 30 diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py index b01925518..4716ada58 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py @@ -35,7 +35,7 @@ Environment: AXIOMCODE_NO_REFRESH=1 turns every trigger off (a query verb's --no-refresh, and the MCP tools' refresh=false, set it for that one query: a read-only answer from the graph as it is, still saying which edits it predates); AXIOMCODE_REFRESH_DEBOUNCE (seconds, default 2) -is the quiet window; AXIOMCODE_REFRESH_BATCH (default 5; 1 = off) is how many edits a rebuild waits for once the last build +is the quiet window; AXIOMCODE_REFRESH_BATCH (default 7; 1 = off) is how many edits a rebuild waits for once the last build took AXIOMCODE_REFRESH_BATCH_ABOVE seconds (default 20); AXIOMCODE_REFRESH_MAX (default 2, 0 = no cap) is how many background rebuilds run at once on the machine, the rest queued; AXIOMCODE_FRESH_WAIT (seconds, default 0) is the most a query whose answer touches an edited file waits for a refresh expected to finish within it, AXIOMCODE_FRESH=1 (--fresh) makes it wait for the refresh whatever it @@ -992,13 +992,13 @@ def running_line(repo, s): return started_line(repo, s, running=True) # a repository whose build takes 5 s that keeps the graph current for free, but where it takes a minute (1,300 Java # files: 48-73 s cold, and 50-88 s again after a one- or two-file edit, since the solve is whole-program) an agent # editing every few seconds kept a rebuild running back to back. So once the last build took AXIOMCODE_REFRESH_BATCH_ABOVE -# seconds (default 20), an edit tool only counts, and the AXIOMCODE_REFRESH_BATCH-th edit (default 5; 1 = off) starts the +# seconds (default 20), an edit tool only counts, and the AXIOMCODE_REFRESH_BATCH-th edit (default 7; 1 = off) starts the # rebuild; a shell command waits for the next edit or checkpoint. The end of a turn, a prompt, a session start, the # timer and every query still refresh at once, so the graph is current whenever the agent stops or asks. EDIT_TOOLS = ('Edit', 'Write', 'MultiEdit', 'NotebookEdit') def batch_size(): - try: return max(1, int(os.environ.get('AXIOMCODE_REFRESH_BATCH') or 5)) + try: return max(1, int(os.environ.get('AXIOMCODE_REFRESH_BATCH') or 7)) except ValueError: return 3 def batching(repo): diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py index a8bb8ec3b..84878058f 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py @@ -271,6 +271,33 @@ def test_impact(d, code): return rows, {}, foot +def head(verb, doc): + """what the question resolved to, before its places: a name that matched several declarations, and for a `'*'` + closure how much it reached by hop. The places are a capped page of the answer, so without this a start that named + 22 declarations read as one, and a closure of thousands of methods as its first ten rows.""" + docs = [(doc.get('language') or '', doc)] + [(l, d) for l, d in (doc.get('other_languages') or {}).items() if isinstance(d, dict)] + out = [] + def say(line): + if line not in out: out.append(line) + for lang, d in docs: + c = d.get('closure') + if verb == 'path' and c: + word = 'everything that can reach ' if c.get('direction') == 'up' else 'everything reached from ' + hops = ', '.join(f"{h}:{n}" for h, n in (c.get('by_hop') or {}).items()) + # one closure per language graph that holds the name: each says which graph it is + say((f"[{lang}] " if len(docs) > 1 and lang else '') + f"{word}{c['label']}: {c['methods']} method(s) in {c['files']} file(s)" + + (f", {c['test']} of them test" if c.get('test') else '') + + (f" within {c['depth']} hop(s)" if c.get('depth') else '') + (f" · by hop: {hops}" if hops else '')) + ends = [t for t in (d.get('targets') or [])] if verb == 'impact' else \ + ([c] if c else list((d.get('ends') or {}).values())) if verb == 'path' else [] + for t in ends: + n = t.get('declarations') or 1 + if n > 1: + say(f"`{str(t.get('label') or '').split(' (')[0]}` matches {n} declarations: the places below are for all of" + " them — name one as Owner.method or file:line to ask about one") + return out + + def verified(v, hops=None): if v is True: return "verified: ✓" + (f" ({hops} edge(s) looked up again)" if hops else '') if v is False: return "verified: ✗ — an edge in this answer is not in the graph; do not use it" @@ -315,7 +342,7 @@ def render(verb, doc, repo, cap=CAP): out.append(f"… +{sum(rest.values())} more not listed: " + ', '.join(f"{n} {k}" for k, n in rest.items()) + f" — {how}") if not rows: return None if left_out: out.append(f"also answered in the {', '.join(left_out)} graph(s), where fewer of the task's words land; --lang asks one") - return out + foot + return head(verb, doc) + out + foot def main(argv): diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode index 6ab4eb364..0f921a7a6 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode @@ -17,6 +17,12 @@ # labelled [asserted]. Alone, lists every link and whether the graph took it; `link -` removes one. # axiomcode index [] [--lang [,…]] [--src ] [--library [,…]] # build the graph (the first query builds it too). defaults to the current directory. +# axiomcode report [] +# where an answer verifiably helped: every Redirected / Helped / Useful chain the end-of-turn receipts recorded, +# as one HTML page (.axiomcode/assists.html). +# axiomcode graph [] +# the codebase as one interactive page: directories, files, types and methods, and the call edges between them. +# Prints the page's path, /.axiomcode/graph/graph.html. # # Names are written as in the code: Owner.method, function, Type, or file.py:123. `axiomcode help ` for one verb. @@ -114,7 +120,7 @@ public_verbs(){ helptext | sed -n 's/^ axiomcode \([a-z][a-z-]*\).*/\1/p'; } # leading comment block. The verb documents itself once, where it is implemented. verbhelp(){ # a verb of the small surface is explained by its own entry in the help above: what it answers, no options - case "$1" in impact|path|tests|link) + case "$1" in impact|path|tests|link|report) helptext | awk -v v="$1" '$0 ~ "^ axiomcode "v"( |$)" {on=1; print; next} on && /^ axiomcode / {exit} on && /^$/ {exit} on {print}' return 0 ;; esac @@ -182,6 +188,7 @@ done LASTPOS=""; [ "${#POS[@]}" -gt 0 ] && LASTPOS="${POS[${#POS[@]}-1]}" case "$cmd" in index|build) if [ "${#POS[@]}" -gt 0 ] && [ ! -d "${POS[0]}" ]; then gone "${POS[0]}"; fi ;; + graph) case "${POS[0]:-}" in ""|build|export|draw) ;; *) [ -d "${POS[0]}" ] || gone "${POS[0]}" ;; esac ;; context) if [ "${#POS[@]}" -gt 1 ] && [ ! -d "${POS[1]}" ]; then gone "${POS[1]}"; fi ;; path) if [ "${#POS[@]}" -gt 2 ] && [ ! -d "${POS[2]}" ]; then gone "${POS[2]}"; fi ;; impact) if [ "${#POS[@]}" -gt 1 ] && [ ! -e "$LASTPOS" ] && dirlike "$LASTPOS"; then gone "$LASTPOS"; fi ;; @@ -234,6 +241,8 @@ case "$cmd" in changed) exec python3 ${Q:+"$Q" "$FR"} "$H/axiomcode-changed" ${ARGS[@]+"${ARGS[@]}"} ;; test-impact|tests) exec ${G[@]+"${G[@]}"} python3 ${Q:+"$Q" "$FR"} "$H/axiomcode-test-impact" ${ARGS[@]+"${ARGS[@]}"} ;; link) exec python3 "$H/axiomcode-link" ${ARGS[@]+"${ARGS[@]}"} ;; + report) exec python3 "$H/ax_assists.py" report ${ARGS[@]+"${ARGS[@]}"} ;; + graph) exec python3 "$H/axiomcode-graph" ${ARGS[@]+"${ARGS[@]}"} ;; --verbs) verbs ;; # the dispatch table, internal verbs included; tests/surfaces.py audits it ""|-h|--help|help) if [ ${#ARGS[@]} -gt 0 ]; then verbhelp "${ARGS[0]}"; else helptext; fi ;; *) echo "axiomcode: unknown subcommand '$cmd' — the entry point has: $(public_verbs | tr '\n' ' ')" >&2 diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-graph b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-graph new file mode 100755 index 000000000..af4b1b6c3 --- /dev/null +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-graph @@ -0,0 +1,308 @@ +#!/usr/bin/env python3 +"""axiomcode graph — the graph of a codebase as one interactive page. + + axiomcode graph [] [--out ] [--no-refresh] + axiomcode graph [] --lang [,…] [--src ] [--library [,…]] [--out …] + +With no flags the page is drawn from the graph `axiomcode index` built, and the engine does not run: + - the graph is up to date (no source file differs from the ones it was built from): the page is written from it + in seconds. + - it is out of date: it is rebuilt first exactly as it was indexed, with the same --lang, --src and --library, so + only the languages the index chose are solved and no graph appears for a language it left out. + - there is no graph yet: the repository is indexed first (every language present, or the flags given). +Flags given here are a request for that graph, as `axiomcode index` takes them. +--no-refresh (MCP refresh=false, or AXIOMCODE_NO_REFRESH=1) is read-only: the page is drawn from the graph that is there +even when it is out of date, and the graph is never rebuilt; the answer says how many files it predates. + +A repository indexed in several languages is drawn in all of them, on one page. The command prints what it drew as +prose, and the page's absolute path last. `axiomcode graph export []` writes the page from the graph as it is, +with no freshness check. + +--library names the dependency roots (for Java the JDK and the third-party sources/IR), and is given to +`axiomcode index`. With them a call into a dependency resolves to a BLACK BOX (one per library type, drawn outside the +disc in its own colour); without them it is unresolved, and the resolution rate is understated. The rate the page +reports is over calls into client code; external calls are counted separately. + +The page (default /.axiomcode/graph/graph.html; one file, no network) draws the codebase as a disc: +directories in the centre, their files on the next ring, the types in those files, and every method on the +outside, each placed under its parent — and the engine's call edges across it, each with its tier. Hover a +node to light up what it calls and what calls it; click it for the detail panel, its impact (everything that +can reach it through resolved calls) or the chain from it. A call the engine could not resolve is a count on +the caller, never a drawn edge. +""" +import os, re, sys, json, time, sqlite3, collections, subprocess + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE); import ax_contract, ax_fresh +TESTRE = re.compile(r'(^|/)(tests?|testing|__tests__|test-data|fixtures?)(/|$)|(^|/)test_|conftest\.py$|/src/test/|Tests?\.java$|\.(spec|test)\.[cm]?[tj]sx?$') +METHOD_KINDS = ('function', 'method', 'constructor', 'module') +TYPE_KINDS = ('class', 'interface', 'enum', 'type', 'namespace') + + +def _b36(n): + s = '' + while n: n, r = divmod(n, 36); s = '0123456789abcdefghijklmnopqrstuvwxyz'[r] + s + return s + + +def graph_dbs(repo, gdir): + """every graph to draw: the main language's, then each other language's (.axiomcode/lang/), as the query verbs + ask them. A graph named in AXIOMCODE_GRAPH was chosen by the caller and is drawn alone.""" + dbs = [os.path.join(gdir, 'out', 'graph.sqlite')] + if not os.environ.get('AXIOMCODE_GRAPH'): + ld = os.path.join(repo, '.axiomcode', 'lang') + for l in sorted(os.listdir(ld)) if os.path.isdir(ld) else (): + d = os.path.join(ld, l, 'out', 'graph.sqlite') + if not os.path.exists(d): continue + why = ax_fresh.graph_corrupt(d) + # another language's corrupt graph is left out of the page, said, rather than ending the page in a traceback + if why: print(f"graph refresh: the {l} graph at {d} is corrupt ({why}) and is left out of this page; `axiomcode index` rebuilds it", file=sys.stderr) + else: dbs.append(d) + return dbs + + +def export(repo, page=None, how=''): + repo = os.path.realpath(repo or '.') + gdir = ax_contract.usable_graph(repo, os.environ.get('AXIOMCODE_GRAPH') or os.path.join(repo, '.axiomcode')) + db = os.path.join(gdir, 'out', 'graph.sqlite') + # the same door every other verb goes through: built on demand under the CLI, refused in words + # that name the one command that helps under anything else. + if not os.path.exists(db) and not ax_contract.ensure_graph(repo, db): sys.exit(ax_contract.no_graph(repo, db)) + # A REPOSITORY IN SEVERAL LANGUAGES is drawn in all of them: one page, each language's graph merged into it (their ids + # carry the language, so none collide; a directory holding two languages' files is one node). No edge joins two + # languages, as no query follows one. + nodes, owners, lib, nesting, anc, langs, runs = {}, {}, {}, {}, collections.defaultdict(set), [], [] + E, unresolved, terminal, LE = {}, collections.Counter(), collections.Counter(), {} + boxes = collections.defaultdict(lambda: dict(sites=0, callers=set(), methods=collections.Counter())) # one black box per library type + def box(c, owner, name): + b = boxes[owner]; b['sites'] += 1; b['callers'].add(c); b['methods'][name] += 1 + le = LE.get((c, owner)); LE[(c, owner)] = dict(s=c, t='L:' + owner, w=(le['w'] + 1) if le else 1) + sites = collections.defaultdict(list) # per caller: [line, written name, target id ('' = none), tier] — what makes a call in the source a link + def site(c, r, target): + if r['line'] and len(sites[c]) < 400: sites[c].append([r['line'], r['written'] or '', target, r['tier']]) + skipped, tiers = None, collections.Counter() + for db in graph_dbs(repo, gdir): + con = sqlite3.connect(db); con.row_factory = sqlite3.Row; ax_contract.on_corrupt(repo, db) + q = lambda sql: con.execute(sql).fetchall() + has = lambda t: bool(con.execute("SELECT 1 FROM sqlite_master WHERE name=?", (t,)).fetchone()) + run = {r['key']: r['value'] for r in q("SELECT key, value FROM run")}; runs.append(run) + if run.get('language') and run['language'] not in langs: langs.append(run['language']) + src = run.get('source_dir') or repo + def rel(p, src=src): + a = p if os.path.isabs(p) else os.path.join(src, p) + a = os.path.realpath(a) if os.path.exists(a) else os.path.normpath(a) + r = os.path.relpath(a, repo).replace(os.sep, '/'); return p.lstrip('/') if r.startswith('..') else r + + # ── declarations: types and methods, with the display names the query skill uses when the index is there ── + owners.update({r['id']: r['owner_type_id'] for r in q("SELECT id, owner_type_id FROM methods WHERE provenance='client'")}) + if has('symbols'): + for r in q("SELECT * FROM symbols WHERE (method_id IS NOT NULL OR type_id IS NOT NULL)"): + if r['method_id'] and r['kind'] in METHOD_KINDS: + nodes[r['method_id']] = dict(id=r['method_id'], k='method', sk=r['kind'], l=r['display'], f=r['file'], a=r['line'], b=r['end_line'], t=int(bool(r['is_test'])), sig=r['signature'] or '') + elif r['type_id'] and r['kind'] in TYPE_KINDS: + nodes[r['type_id']] = dict(id=r['type_id'], k='type', sk=r['kind'], l=r['display'], f=r['file'], a=r['line'], b=r['end_line'], t=int(bool(r['is_test']))) + else: + cat = lambda c: 'interface' if 'INTERFACE' in c else 'enum' if 'ENUM' in c else 'namespace' if 'NAMESPACE' in c else 'type' if 'ALIAS' in c or 'TYPEDEF' in c else 'class' + for r in q("SELECT * FROM types WHERE provenance='client'"): + f = rel(r['file_path']); nodes[r['id']] = dict(id=r['id'], k='type', sk=cat(r['category']), l=r['name'].replace('$', '.'), f=f, a=r['start_line'], b=r['end_line'], t=int(bool(TESTRE.search(f)))) + for r in q("SELECT * FROM methods WHERE provenance='client'"): + f = rel(r['file_path']); o = (r['owner_qualified_name'] or '').replace('#', '.').split('.')[-1].replace('$', '.') + sk = 'constructor' if 'CONSTRUCTOR' in r['kind'] else 'module' if 'MODULE' in r['kind'] else 'method' if r['owner_type_id'] else 'function' + nodes[r['id']] = dict(id=r['id'], k='method', sk=sk, l=f"{o}.{r['name']}" if o else r['name'], f=f, a=r['start_line'], b=r['end_line'], t=int(bool(TESTRE.search(f))), sig=r['signature'] or '') + lib.update({r['id']: (r['owner_qualified_name'] or r['qualified_name'].rsplit('.', 1)[0], r['name']) for r in q("SELECT id, qualified_name, name, owner_qualified_name FROM methods WHERE provenance<>'client'")}) + if has('nesting'): nesting.update({r['type_id']: r['outer_type_id'] for r in q("SELECT type_id, outer_type_id FROM nesting")}) + + # ── calls: one edge per (caller, callee) with the best tier and the site count; unresolved → a count on the caller ── + # only an ambiguous_* tier is unresolved. boundary_lib is a call into a library; the *_terminal, implicit_constructor and + # fan_capped tiers are correct ends (the platform, a synthesised constructor, a computed target) — the same rule as the + # query skill, so the two never disagree about what is a blind spot + for r in q("SELECT e.caller_id, e.callee_method_id, e.tier, e.callee_label, s.start_line AS line, s.callee_name AS written FROM call_edges e LEFT JOIN call_sites s ON s.id = e.call_site_id"): + c, t = r['caller_id'], r['callee_method_id'] + if c not in nodes: continue + if not t or t not in nodes: + if t in lib: box(c, *lib[t]); site(c, r, 'L:' + lib[t][0]) + elif r['tier'] == 'boundary_lib' and r['callee_label']: + # Python / JavaScript: the library target is a label — builtin:len, external:asyncio.get_running_loop — not a method row + prov, _, name = r['callee_label'].partition(':'); owner, _, meth = name.rpartition('.') + owner = owner or (('builtins' if prov == 'builtin' else name)); box(c, owner, meth or name); site(c, r, 'L:' + owner) + elif (r['tier'] or '').startswith('ambiguous'): unresolved[c] += 1; site(c, r, '') + else: terminal[c] += 1 + continue + site(c, r, t) + e = E.get((c, t)) + if e is None: E[(c, t)] = dict(s=c, t=t, tier=r['tier'], w=1) + else: + e['w'] += 1 + if r['tier'] == 'known_edge': e['tier'] = 'known_edge' + # a call through a holder the engine could name (#1206): the caller reaches every function the holder may hold. The call + # site itself resolves to the holder's bodiless base (Function.apply), so without these the stored function has no caller + if has('ext_fn_value_call'): + for r in q("SELECT DISTINCT c0, c1 FROM ext_fn_value_call"): + c, t = r[0], r[1] + if c in nodes and t in nodes and (c, t) not in E: E[(c, t)] = dict(s=c, t=t, tier='fn_value', w=1) + for r in q("SELECT method_id, reason FROM entry_points"): + if r['method_id'] in nodes: nodes[r['method_id']].setdefault('e', []).append(r['reason']) + for r in q("SELECT type_id, ancestor_type_id FROM type_ancestors"): anc[r['type_id']].add(r['ancestor_type_id']) + # two shapes share the name: the bundle's (file_path, reason, construct, start_line, …) and the index's (file, reason), which + # the index writes over the bundle's — read whichever is there + if has('skipped'): + cols = {r[1] for r in q("PRAGMA table_info(skipped)")}; col = lambda c: c if c in cols else 'NULL' + fc = 'file_path' if 'file_path' in cols else 'file' + skipped = (skipped or []) + [dict(f=rel(r[0]), r=r[1] or '', a=r[2], d=r[3] or '') for r in q(f"SELECT {fc}, reason, {col('start_line')}, {col('detail')} FROM skipped WHERE {fc} <> '' ORDER BY reason, {fc}")] + tiers.update(r[0] for r in q("SELECT tier FROM call_edges")) + con.close() + + # ── containment: dir → file → type → method (nested types under their outer; free functions under the file) ── + for n in list(nodes.values()): + fid = 'F:' + n['f'] + if fid not in nodes: + d = os.path.dirname(n['f']) or '.' + nodes[fid] = dict(id=fid, k='file', l=n['f'], f=n['f'], t=int(bool(TESTRE.search(n['f']))), p='D:' + d) + if 'D:' + d not in nodes: nodes['D:' + d] = dict(id='D:' + d, k='dir', l=d, f='', t=int(bool(TESTRE.search(d + '/')))) + if n['k'] == 'type': n['p'] = nesting[n['id']] if nesting.get(n['id']) in nodes else fid + elif n['k'] == 'method': o = owners.get(n['id']); n['p'] = o if o in nodes else fid + for m, n in unresolved.items(): nodes[m]['u'] = n + for m, ss in sites.items(): nodes[m]['cs'] = ss + for owner, b in boxes.items(): + nodes['L:' + owner] = dict(id='L:' + owner, k='lib', l=owner, f='', sites=b['sites'], callers=len(b['callers']), methods=[f"{k}×{v}" if v > 1 else k for k, v in b['methods'].most_common(8)]) + X = [] # extends / implements: direct ancestors only (type_ancestors is a closure) + for t, A in anc.items(): + if t in nodes: + for a in A: + if a in nodes and not any(b != a and a in anc.get(b, ()) for b in A): X.append(dict(s=t, t=a)) + deg = collections.Counter() + for e in E.values(): deg[e['s']] += 1; deg[e['t']] += 1 + for e in LE.values(): deg[e['t']] += 1 + for n in nodes.values(): + if deg[n['id']]: n['d'] = deg[n['id']] + + sites = sum(tiers.values()); unres = sum(v for t, v in tiers.items() if t.startswith('ambiguous')) + client = tiers.get('known_edge', 0) + tiers.get('multi_inferred', 0); external = sum(b['sites'] for b in boxes.values()) + terminal_n = sites - unres - client - external + data = dict(meta=dict(repo=os.path.basename(repo), language=', '.join(langs) or '?', built=time.strftime('%Y-%m-%d %H:%M'), sites=sites, + client_sites=client, external_sites=external, terminal_sites=terminal_n, unresolved=unres, boxes=len(boxes), + client_rate=round(100 * client / max(1, sites - external - terminal_n)), overall_rate=round(100 * (sites - unres) / max(1, sites)), + tiers=dict(tiers), library_roots=runs[0].get('library_roots', '') or ('java' not in langs and 'n/a') or '', counts=dict(collections.Counter(n['k'] for n in nodes.values())), edges=len(E)), + nodes=list(nodes.values()), calls=list(E.values()), libcalls=list(LE.values()), extends=X, skipped=skipped) + data['meta']['fn_value_edges'] = sum(1 for e in E.values() if e['tier'] == 'fn_value') + short = {} + def key(i): + k = short.get(i) + if k is None: n = len(short); k = short[i] = ('L:' if i.startswith('L:') else '') + (_b36(n) or '0') + return k + for n in data['nodes']: + n['id'] = key(n['id']) + if n.get('p'): n['p'] = key(n['p']) + if n['k'] == 'file': n.pop('l', None) # a file's label is its path, already in f + for s in n.get('cs', ()): s[2] = key(s[2]) if s[2] else '' + for e in data['calls'] + data['libcalls'] + data['extends']: e['s'], e['t'] = key(e['s']), key(e['t']) + if page and (os.path.isdir(page) or not page.lower().endswith('.html')): page = os.path.join(page, os.path.basename(repo) + '.html') # --out → /.html + page = page or os.path.join(gdir, 'graph', 'graph.html'); os.makedirs(os.path.dirname(page), exist_ok=True) + # the sources, so the page can show the code a node points at (one file, still no network); skipped past 40 MB + files, size = {}, 0 + for f in sorted({n['f'] for n in nodes.values() if n.get('f')}): + try: + t = open(os.path.join(repo, f), encoding='utf-8', errors='replace').read(); size += len(t) + if size > 40_000_000: files = {}; break + files[f] = t + except OSError: pass + data['files'] = files; data['meta']['repo_path'] = repo; data['meta']['sources'] = bool(files) + tpl = open(os.path.join(HERE, 'viewer.html'), encoding='utf-8').read() + open(page, 'w', encoding='utf-8').write(tpl.replace('/*__DATA__*/null', json.dumps(data, separators=(',', ':')).replace(',") + print(f"page: {os.path.abspath(page)}") + + +def run_build(repo, env=None): + """axiomcode-build on repo, its progress on this process's stdout; exits with its status when it fails""" + cands = [os.path.join(HERE, 'axiomcode-build'), os.path.join(os.environ.get('AXIOMCODE_ENGINE', ''), 'skills', 'axiomcode', 'scripts', 'axiomcode-build')] + b = next((os.path.realpath(c) for c in cands if os.path.isfile(c)), None) + if not b: sys.exit("axiomcode-build not found beside this script (set AXIOMCODE_ENGINE to the engine checkout)") + sys.stdout.flush() + r = subprocess.run([os.environ.get('AXIOMCODE_BASH') or 'bash', b, repo], env=env) # a bash script: Windows cannot exec it itself + if r.returncode: sys.exit(r.returncode) + + +def recorded(repo): + """the file table of the graph that is there: the languages, --src and --library it was indexed with. A graph from + before the table has its main language, --src and --library in its run table; its other languages are the graphs + beside it. None when there is nothing to read them from.""" + import ax_fresh + t = ax_fresh.load_table(repo) + if t: return t + t = ax_fresh.legacy_params(repo) + if not t: return None + ld = os.path.join(repo, '.axiomcode', 'lang') + others = sorted(l for l in (os.listdir(ld) if os.path.isdir(ld) else ()) if os.path.exists(os.path.join(ld, l, 'out', 'graph.sqlite'))) + return dict(t, lang=','.join([t['lang']] + [l for l in others if l != t['lang']]), lang_auto=False, legacy=True) + + +def build(repo, page, library=None): + """the page, from the graph that is there when it is current, else from a rebuild of it with the flags it was indexed + with. THE ENGINE RUNS ONLY WHEN IT HAS TO, AND ONLY FOR THE LANGUAGES THE INDEX CHOSE. This called axiomcode-build with + no flags, which detects every language in the tree: on a repository indexed with --lang python its answer never matched + the recorded graph, so every call rebuilt the graph from scratch in every language present, a JavaScript or TypeScript + compile for a few stray files included, and left a graph for each of them that later queries answered from.""" + import ax_fresh + repo = os.path.realpath(repo or '.') + if library: os.environ['AXIOMCODE_LIBRARY'] = ','.join(os.path.abspath(os.path.expanduser(x)) for x in library.split(',')) + db = os.path.join(repo, '.axiomcode', 'out', 'graph.sqlite') + asked = [f"{k}={os.environ[v]}" for k, v in (('--lang', 'AXIOMCODE_LANG'), ('--src', 'AXIOMCODE_SRC'), ('--library', 'AXIOMCODE_LIBRARY')) if os.environ.get(v)] + if os.environ.get('AXIOMCODE_GRAPH'): return export(repo, page) # a graph someone else built and placed: drawn as it is + ax_fresh.relink(repo) # never another checkout's graph (#1605) + if not os.path.lexists(db): + # no graph yet: the first index, with what was asked for (every language present, when nothing was) + t0 = time.time(); run_build(repo) + return export(repo, page, how=f"no graph yet: indexed {repo}{' with ' + ' '.join(asked) if asked else ''} in {time.time() - t0:.0f} s.") + if asked: + # flags given here are a request for that graph: axiomcode-build keeps the one there when it already is that graph + t0 = time.time(); run_build(repo) + return export(repo, page, how=f"graph built with {' '.join(asked)} ({time.time() - t0:.0f} s).") + t = recorded(repo) + if t is None: + return export(repo, page, how="drawn from the graph as it is: it records no index flags to rebuild it with.") + c = None if t.get('legacy') else ax_fresh.changes(repo, t) + langs = (t.get('lang') or '?').replace(',', ', ') + if c is not None and not any(c) and not ax_fresh.graph_broken(repo): + return export(repo, page, how=f"graph up to date ({langs}): drawn from it, no rebuild.") + n = sum(len(x) for x in c) if c else 0 + if os.environ.get('AXIOMCODE_NO_REFRESH') and not ax_fresh.graph_broken(repo): + # READ-ONLY (--no-refresh, MCP refresh=false, AXIOMCODE_NO_REFRESH=1): the graph there is drawn as it is, and said + return export(repo, page, how=f"refresh off (--no-refresh): drawn from the graph as it is, not rebuilt ({langs})" + + (f"; it predates edits to {n} file(s) since it was built" if n else '') + ".") + flags = ' '.join(f for f in (('--lang ' + t['lang']) if not t.get('lang_auto', True) else 'every language present', + ('--src ' + t['src_arg']) if t.get('src_arg') else '', ('--library ' + t['library']) if t.get('library') else '') if f) + print(f"the graph is out of date ({n} file(s) changed since it was built): rebuilding it as it was indexed ({flags}) …" if n else + f"rebuilding the graph as it was indexed ({flags}) …", flush=True) + # a REFRESH, not an index: the baseline `changed` and `test-impact` measure edits against stays where it was, as the + # background refresh keeps it. Rebuilt as an explicit index it moved to the edited tree, and the edits it held dropped + # out of both ("no changed declaration" over a real edit) + t0 = time.time(); run_build(repo, ax_fresh.rebuild_env(t, AXIOMCODE_REFRESH_REASON='axiomcode graph', AXIOMCODE_KEEP_BASE='1')) + return export(repo, page, how=f"graph rebuilt ({flags}) in {time.time() - t0:.0f} s.") + + +if __name__ == '__main__': + a = sys.argv[1:]; page = None; library = None + if '--out' in a: i = a.index('--out'); page = os.path.abspath(os.path.expanduser(a[i + 1])); del a[i:i + 2] + if '--library' in a: i = a.index('--library'); library = a[i + 1]; del a[i:i + 2] + if a and a[0] in ('-h', '--help'): print(__doc__.strip()); sys.exit(0) + # `axiomcode graph []` is the verb; `build` and `export` are the older spellings, still taken. A directory + # named `build` or `export` is a repository, not a subcommand + verb = a[0] if a and a[0] in ('build', 'export', 'draw') and not os.path.isdir(a[0]) else 'draw' + pos = a[1:] if a and verb == a[0] else a + if len(pos) > 1 or any(x.startswith('-') for x in pos): print(__doc__.strip(), file=sys.stderr); sys.exit(2) + if verb == 'export': export(pos[0] if pos else '.', page, how="drawn from the graph as it is (export: no freshness check, no rebuild).") + else: build(pos[0] if pos else '.', page, library) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 121c6716a..174ed9c28 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -169,6 +169,14 @@ def also_text(whys, shown=2): return '; also: ' + '; '.join(whys[:shown]) + (f" (+{len(whys) - shown} more)" if len(whys) > shown else '') +def declarations_of(label): + """how many declarations a target's label says the name matched: `total (4 declarations)` is 4, a local's + `local x in f (+2 declarations)` is 3, anything else 1. Every resolver writes the count into the label, so the + --json document carries it from there rather than from each resolver's own shape of ids""" + m = re.search(r'\((\+?)(\d+) declarations\)', label or '') + return 1 if not m else int(m.group(2)) + (1 if m.group(1) else 0) + + def merge_reasons(why, others): """-> (why, others left): reasons that differ from the row's only in their last word -- `using the same field a`, `... field b` -- are said once, `using the same field a, b`, rather than as a sentence each""" @@ -3593,7 +3601,7 @@ def main(argv): fw_grep = ("grep -rnF " + ' '.join(f"-e '\"{p_}'" for p_ in pre) + f" {P.grep_roots(g)}") if pre else \ ("grep -rnw " + ' '.join(f'-e "{n}"' for n in names) + f" {P.grep_roots(g)}" if names else '') if as_json: - doc = ({'targets': [{'kind': k, 'label': lab} for k, lab, _ in targets], + doc = ({'targets': [{'kind': k, 'label': lab, 'declarations': declarations_of(lab)} for k, lab, _ in targets], 'contract': [{'id': c, 'display': g.disp(c), 'why': why, 'also': contract_also.get(c, []), 'at': g.loc(c), 'for': sorted(contract_for[c])} for c, why in contract], # the sort key ends in x[0], the id: two distinct anon-class methods can share a # display AND every other component of the key, and without the id the order is diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index 741eaa4d3..018e0ddae 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -1614,6 +1614,14 @@ def closure(g, sel, upstream, limit=40, depth=40): agree = bfs == dict(rows) word = 'can reach' if upstream else 'is reached from' print(f"{'everything that ' + word + ' ' if upstream else 'everything reached from '}{label}: {len(rows)} method(s) in {len({g.sym[m]['file'] for m, _ in rows})} file(s)" + (f" within {depth} hop(s)" if depth < 40 else '')) + # THE SUMMARY AS DATA. The places view (ax_blocks: MCP and the CLI's default) is built from this document, not from + # the line above, and without these fields it opened at row 1 of thousands: a start that named 22 declarations read + # as one, and nothing said how many places there were per hop. + RESULT['closure'] = {'direction': 'up' if upstream else 'down', 'label': label, 'declarations': len(ids), + 'methods': len(rows), 'files': len({g.sym[m]['file'] for m, _ in rows}), + 'test': sum(1 for m, _ in rows if g.sym[m]['is_test']), + 'by_hop': {str(d): n for d, n in sorted(collections.Counter(d for _, d in rows).items())}, + 'depth': depth if depth < 40 else None} # HOW MUCH OF IT IS CERTAIN. The closure follows every edge the graph holds, and most of a large one arrives through a # dispatch choice: a call to an interface method "reaches" every implementation's callers, so one repository interface # turned 3 exact callers into 342 methods. Both numbers are true -- the first is what must run it, the second what may -- @@ -1945,6 +1953,9 @@ def never_loads(g, A, B): def path(g, a, b, show_all=False, limit=10, every=False, max_paths=20): g.export() la, A = g.resolve(a); lb, B = g.resolve(b) + # what each end named, as data: the places view shows the hops, and a name that matched several declarations + # has to say so there too (ax_grep.head) + RESULT['ends'] = {'from': {'label': la, 'declarations': len(A)}, 'to': {'label': lb, 'declarations': len(B)}} nl = never_loads(g, A, B) if nl: RESULT['never_loads'] = {'test_file': nl[0], 'target_files': nl[1]} diff --git a/plugins/axiomcode/skills/axiomcode/scripts/viewer.html b/plugins/axiomcode/skills/axiomcode/scripts/viewer.html new file mode 100644 index 000000000..4ca6ef0f2 --- /dev/null +++ b/plugins/axiomcode/skills/axiomcode/scripts/viewer.html @@ -0,0 +1,344 @@ + + + + +axiomcode graph + + + + +

    axiomcode graph

    +
    +
    +
    a call in the code is a link: click opens the callee below · ⌘/Ctrl-click goes there · grey = one target · cyan = a sound set · orange = library · amber = the engine could not resolve it
    +
    +
    +

    node types · click to hide/show

    +

    only · click to narrow

    +

    edges

    +

    focus depth · hops from the selection

    +
    +
    +
    +
    scroll to zoom · drag to pan · hover a dot · click to select · double-click to zoom in
    + +
    + + + diff --git a/skills/axiomcode/SKILL.md b/skills/axiomcode/SKILL.md index ba96f81fe..ae84f0056 100644 --- a/skills/axiomcode/SKILL.md +++ b/skills/axiomcode/SKILL.md @@ -1,23 +1,25 @@ --- 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; 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#. + 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 Search with grep as usual; the graph answers what grep cannot. Use the MCP tools when they are in your list (in Claude Code -`mcp__plugin_axiomcode_axiomcode__impact`, `__path`, `__tests`); otherwise run +`mcp__plugin_axiomcode_axiomcode__impact`, `__path`, `__tests`, `__context`); otherwise run `/../../plugins/axiomcode/skills/axiomcode/scripts/axiomcode ` from the repository root. Same answer either way. | the question | MCP tool | shell | |---|---|---| | where is the code for this task? | your own search (grep), then bring the name here | — | | who calls X, what does changing it reach, which tests? | `impact(name)` | `axiomcode impact ` | +| what is the value of constant X, and who reads it? | `impact(name)` | `axiomcode impact ` | | what do my uncommitted edits reach? | `impact()` | `axiomcode impact` | | how does A reach B? | `path(start, end)` | `axiomcode path ` | | which tests do my edits need, and how do I run them? | `tests()` | `axiomcode tests` | | an answer lists an unresolved call I can see the target of | `link(site, target)` | `axiomcode link ` | +| how does this work, start to finish? | `context(task, source=True)` | `axiomcode context "" --source` | Names are written as in the code: `Owner.method`, `function`, `Type`, or `file.py:123` for the declaration at that line. There is no setup step: the first question builds the graph, and it refreshes itself after every edit. @@ -45,7 +47,8 @@ from an empty answer. With a name: who calls it, what depends on it further out, and the tests that exercise it. Example: `impact(name="PriceService.total")`. With no name: the first line is `your edits:` (each declaration you changed and -how), then the same answer for all of them. +how), then the same answer for all of them. A constant answers with its value — `change: const MAX_ITEMS = 5` — +so a limit, a default or a threshold is read off the first line rather than from the file. ## path @@ -87,10 +90,55 @@ identifier as written in the code. ## index -`axiomcode index` builds the graph explicitly; `--lang`, `--src` and `--library` narrow it. Never re-run it on an +`axiomcode index` builds the graph explicitly; `--lang` and `--src` narrow it. Never re-run it on an existing graph: the graph rebuilds itself after edits, and an answer given before that finishes says so on a `graph refresh:` line. +By default the graph is built without the project's dependencies: a call into one is unresolved or named only as an +external boundary. When better coverage is needed, compile the dependencies and enable them with `--library`, given +once on `index` and kept by every rebuild after it: + + axiomcode index --library auto # the dependencies the project imports + axiomcode index --library .venv/lib/python3.12/site-packages/requests,/deps/ir/jdk # or name them + +`auto` finds what the project depends on, in every language: a Python package its source imports, in its virtual +environment (`.venv`, `venv`, `$VIRTUAL_ENV`); a JavaScript / TypeScript package it imports, under `node_modules`; a +Java dependency `pom.xml` or `build.gradle` declares, through its `-sources.jar` in the Maven repository or Gradle's +cache, or its class jar decompiled when it ships no sources (needs Vineflower: `mvn dependency:get +-Dartifact=org.vineflower:vineflower:1.10.1`); a NuGet package a `.csproj` references, decompiled from its assembly +(needs `dotnet tool install -g ilspycmd`). Whatever it cannot find (a package never restored, a jar without sources) +it names, with the command that fetches it. A named entry, comma-separated with no spaces, is a dependency's source +directory or a library IR (a directory of the parser's CSV tables, as `axiomcode parser --library` +writes it). Every library is compiled once into `~/.cache/axiomcode/libir/` and reused until its files change; one no +build has used for 30 days is removed. Entries are relative to the repository or absolute; write `~` out, since one +after a comma is not expanded. `AXIOMCODE_LIBRARY` takes the same list. + +The choice is kept with the graph. Every rebuild after it stages the libraries again (`auto` discovers them +again, so a new dependency is picked up), and with libraries on, a dependency change alone (a manifest or lockfile +edit, `pip install -U`, `npm install`, `dotnet restore`, a Maven or Gradle fetch of a dependency that was missing) +also marks the graph stale and rebuilds it. A graph built +without `--library` never gains libraries by itself; `AXIOMCODE_REINDEX=1 axiomcode index` turns them off again. + +With them, a call into a dependency resolves to its declaration and a chain is typed through its declared return +types (`client.post(...).json()`). Library bodies are not walked, so a dependency calling back into the project is +not found this way. + +## report + +At the end of a turn where an answer verifiably led your work (a file the answer showed first that you then read or +edited, or the tests it picked that you ran: Redirected / Helped / Useful), the plugin hands you a receipt once. End +your reply with it exactly as written. It is read from the session's tool calls, not from what you write, so there is +nothing to claim yourself; if the reply does not carry it, the user is shown it anyway. +Each repository keeps its own record (`.axiomcode/assists.jsonl` at its root) and its own page, +`.axiomcode/assists.html`, rebuilt whenever a turn adds to the record; `axiomcode report` rebuilds it by hand. + +## graph + +`axiomcode graph` draws the codebase as one page for the user to look at: directories, files, types and methods, with +the call edges between them and each edge's tier. It prints the page's path (`.axiomcode/graph/graph.html`); open it +in a browser. It is drawn from the graph the index built, rebuilt first only when a source file changed. It is not an +MCP tool: it answers no question, so when the user asks to see the graph, run it and give them the path. + ## What it cannot see — say so instead of guessing Reflection, string dispatch, event buses; receivers the engine could not type; callbacks invoked by a library; what a diff --git a/tests/README.md b/tests/README.md index 93114507b..6b25a253e 100644 --- a/tests/README.md +++ b/tests/README.md @@ -12,6 +12,9 @@ the behaviour it is about, so a failure says what broke rather than which number One check needs no graph and is its own script: python3 tests/surfaces.py every dispatched verb is documented on --help, SKILL.md and MCP + python3 tests/answer_head.py the places view (MCP, the installed command) opens with what the question resolved + to: a closure's size and count per hop, and a name that matched several + declarations; a single declaration adds nothing (#1939; no graph needed) 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) diff --git a/tests/answer_head.py b/tests/answer_head.py new file mode 100644 index 000000000..e5f795a47 --- /dev/null +++ b/tests/answer_head.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""tests/answer_head.py — the places view says what the question resolved to before its places. + +The front door (MCP and the installed `axiomcode`) answers impact, path and tests as numbered places built from the +verb's --json document (ax_blocks.py), capped at ten. The verb's own summary was prose and never reached that view, so +`path main '*'` on a tree with 22 functions named `main` opened at a test script's row 1 and read as one start, and a +closure of thousands of methods showed no count and no hop distribution. Each document below is the shape the verb +writes; no graph and no engine are needed. + + python3 tests/answer_head.py +""" +import os, sys, tempfile + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts')) +import ax_blocks, ax_grep + +fails = [] +def check(ok, why, detail=''): + print(('ok ' if ok else 'FAIL ') + why + ('' if ok else '\n ' + str(detail).replace('\n', '\n '))) + if not ok: fails.append(why) + + +def closure(label, n, up=False, test=0): + return {'direction': 'up' if up else 'down', 'label': label, 'declarations': n, 'methods': 7, 'files': 2, + 'test': test, 'by_hop': {'1': 3, '2': 4}, 'depth': None} + + +repo = tempfile.mkdtemp(prefix='ax-head-') +with open(os.path.join(repo, 'a.ts'), 'w') as f: + f.write('function main() {\n run();\n}\nfunction run() {}\n') +reached = [{'name': 'run', 'at': 'a.ts:4', 'hops': 1}] + +# a '*' closure from a name with several declarations: the summary and the ambiguity both lead the answer +doc = {'reached': reached, 'closure': closure('main (22 declarations)', 22, test=5), 'verified': True} +out = ax_blocks.render('path', doc, repo) +check(out and out[0].startswith('everything reached from main (22 declarations): 7 method(s) in 2 file(s), 5 of them test'), + 'a closure opens with what it reached, before row 1', out) +check(out and 'by hop: 1:3, 2:4' in out[0], 'the closure line carries the count per hop', out) +check(any(l.startswith('`main` matches 22 declarations') for l in out or []), 'a start naming 22 declarations says so', out) + +# upstream closure +doc = {'reached': reached, 'closure': closure('run', 1, up=True), 'verified': True} +out = ax_blocks.render('path', doc, repo) +check(out and out[0].startswith('everything that can reach run: 7 method(s)'), "path '*' X opens with what can reach X", out) +check(not any('matches' in l for l in out or []), 'control: a one-declaration end adds no ambiguity line', out) + +# a chain whose start is ambiguous; and its control, a chain whose ends are one declaration each +hop = {'to': 'run', 'tier': 'known_edge', 'call_at': 'a.ts:2', 'is_call': True} +amb = {'answers': [{'from': 'main', 'hops': [hop]}], 'verified': True, + 'ends': {'from': {'label': 'main (3 declarations)', 'declarations': 3}, 'to': {'label': 'run', 'declarations': 1}}} +out = ax_blocks.render('path', amb, repo) +check(out and out[0].startswith('`main` matches 3 declarations'), 'path A B: an ambiguous end is named first', out) +one = dict(amb, ends={'from': {'label': 'main', 'declarations': 1}, 'to': {'label': 'run', 'declarations': 1}}) +check(ax_blocks.render('path', one, repo)[0].startswith('1. a.ts:2'), 'control: a chain between single declarations is unchanged', ax_blocks.render('path', one, repo)) +legacy = {k: v for k, v in amb.items() if k != 'ends'} +check(ax_blocks.render('path', legacy, repo)[0].startswith('1. a.ts:2'), 'control: a document without the new fields renders as before', ax_blocks.render('path', legacy, repo)) + +# impact on a name with several declarations, and its control +imp = {'targets': [{'kind': 'method', 'label': 'run (4 declarations)', 'declarations': 4}], + 'direct': [{'id': 'm', 'display': 'main', 'at': 'a.ts:2', 'certainty': 'resolved', 'why': 'calls it'}], 'verified': True} +out = ax_blocks.render('impact', imp, repo) +check(out and out[0].startswith('`run` matches 4 declarations'), 'impact: an ambiguous name is named first', out) +imp1 = dict(imp, targets=[{'kind': 'method', 'label': 'run', 'declarations': 1}]) +check(ax_blocks.render('impact', imp1, repo)[0].startswith('1. a.ts:2'), 'control: impact on one declaration is unchanged', ax_blocks.render('impact', imp1, repo)) + +# several language graphs: each closure line names its graph +multi = {'language': 'typescript', 'reached': reached, 'closure': closure('main (22 declarations)', 22), + 'other_languages': {'java': {'reached': [], 'closure': closure('main', 1)}}} +h = ax_grep.head('path', multi) +check(h[0].startswith('[typescript] everything reached from main (22 declarations)') and any(l.startswith('[java] everything reached from main:') for l in h), + 'with several graphs, each closure line names its graph', h) +check(not ax_grep.head('path', {'reached': reached, 'closure': closure('main', 1)})[0].startswith('['), 'control: one graph, no graph label') + +# the grep view (`--grep`) leads with the same lines +g = ax_grep.render('path', {'reached': reached, 'closure': closure('main (22 declarations)', 22)}, repo) +check(g and g[0].startswith('everything reached from main (22 declarations)'), 'the grep view leads with the same summary', g) + +print(f"\n{'FAIL' if fails else 'ok'}: {len(fails)} failure(s)") +sys.exit(1 if fails else 0) diff --git a/tests/assists.py b/tests/assists.py new file mode 100644 index 000000000..a278cef51 --- /dev/null +++ b/tests/assists.py @@ -0,0 +1,388 @@ +#!/usr/bin/env python3 +"""tests/assists.py — the Stop hook's receipt (#1935) credits an answer only for what the session shows it led to. + +Each check writes a small transcript in the host's shape (prompts, tool calls, their results) into a scratch git +repository, and asks the rules what they find. A rule is checked from both sides: the case it must credit, and a near +case that differs only where the rule reads and must not be credited. No engine and no model are needed. + + python3 tests/assists.py +""" +import json, os, subprocess, sys, tempfile + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +HOOKS = os.path.join(ROOT, 'plugins', 'axiomcode', 'hooks') +sys.path.insert(0, os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts')) +import ax_assists as A + +fails = [] +def check(why, cond, detail=''): + print(('ok ' if cond else 'FAIL ') + why + (f'\n {detail}' if not cond and detail else '')) + if not cond: + fails.append(why) + + +MCP = 'mcp__plugin_axiomcode_axiomcode__impact' +IMPACT_A = '1. src/pkg/alpha.py:10 [resolved · Alpha.run]\n ```python\n → 10 self.beta()\n ```\nverified: ✓' +TESTS_A = '1. tests/test_alpha.py:5 [test]\nrun them: pytest tests/test_alpha.py' + + +class T: + """a transcript under construction""" + def __init__(self, root): + self.root, self.lines, self.n = root, [], 0 + + def prompt(self, text): + self.lines.append({'type': 'user', 'message': {'role': 'user', 'content': text}}) + return self + + def call(self, name, inp, result='', error=False, side=False, mid=None): + """mid: a message id shared with other calls, for calls sent together in one message""" + self.n += 1 + tid = f'toolu_{self.n:03d}' + self.lines.append({'type': 'assistant', 'isSidechain': side, + 'message': {'role': 'assistant', 'id': mid or f'msg_{self.n:03d}', + 'content': [{'type': 'tool_use', 'id': tid, 'name': name, 'input': inp}]}}) + self.lines.append({'type': 'user', 'isSidechain': side, + 'message': {'role': 'user', 'content': [{'type': 'tool_result', 'tool_use_id': tid, + 'content': result, 'is_error': error}]}}) + return self + + def read(self, rel): + return self.call('Read', {'file_path': os.path.join(self.root, rel)}, 'contents') + + def edit(self, rel): + return self.call('Edit', {'file_path': os.path.join(self.root, rel), 'old_string': 'a', 'new_string': 'b'}, 'ok') + + def note(self, text, kind='hook_additional_context', event='PostToolUse', stdout=None): + """a hook's output put in front of the agent, as the host records it""" + self.n += 1 + a = {'type': kind, 'hookName': event, 'hookEvent': event, 'content': [text] if kind == 'hook_additional_context' else text} + if stdout is not None: + a['stdout'] = stdout + self.lines.append({'type': 'attachment', 'uuid': f'u{self.n:03d}', 'attachment': a}) + return self + + def save(self): + p = os.path.join(self.root, '..', f'transcript-{id(self)}.jsonl') + with open(p, 'w') as f: + for l in self.lines: + f.write(json.dumps(l) + '\n') + return p + + +def repo(): + d = os.path.realpath(tempfile.mkdtemp(prefix='ax-assists-')) + r = os.path.join(d, 'repo') + os.makedirs(os.path.join(r, 'src', 'pkg')); os.makedirs(os.path.join(r, 'tests')) + for f in ('src/pkg/alpha.py', 'src/pkg/beta.py', 'src/pkg/gamma.py', 'tests/test_alpha.py'): + open(os.path.join(r, f), 'w').write('x = 1\n') + for c in (['init', '-q'], ['add', '-A'], ['-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'base']): + subprocess.run(['git', '-C', r] + c, check=True) + return r + + +def change(r, rel): + open(os.path.join(r, rel), 'a').write('y = 2\n') + + +def found(t, r, diff=True): + return A.chains(A.steps(t.save()), r, A.working_diff(r) if diff else None) + + +# --- Helped: first seen in the answer, then read and edited, and the edit kept +r = repo(); change(r, 'src/pkg/alpha.py') +t = T(r).prompt('fix the bug in run').call(MCP, {'name': 'Alpha.run'}, IMPACT_A).read('src/pkg/alpha.py').edit('src/pkg/alpha.py') +c = found(t, r) +check('an answer that showed a file the agent then edited is Helped', [x['label'] for x in c] == ['Helped'], c) +check('the chain carries the tool calls it stands on', c and c[0]['evidence']['edit'] and c[0]['evidence']['answer'] == 'toolu_001', c) + +# --- an MCP answer wrapped as {"result": …}, the file at the start of a line +r = repo(); change(r, 'src/pkg/alpha.py') +t = (T(r).prompt('fix the bug in run').call(MCP, {'name': 'Alpha.run'}, json.dumps({'result': 'further away:\nsrc/pkg/alpha.py:10 run [hop 2]'})) + .edit('src/pkg/alpha.py')) +check('an MCP answer wrapped in {"result": …} is read with its line breaks', [x['label'] for x in found(t, r)] == ['Helped'], found(t, r)) + +# --- the same, but the prompt already named the file: not first seen, no credit +r = repo(); change(r, 'src/pkg/alpha.py') +t = T(r).prompt('fix the bug in src/pkg/alpha.py').call(MCP, {'name': 'Alpha.run'}, IMPACT_A).edit('src/pkg/alpha.py') +check('a file the prompt named is not credited to the answer', found(t, r) == [], found(t, r)) + +# --- an earlier tool result named it (a grep hit): no credit +r = repo(); change(r, 'src/pkg/alpha.py') +t = (T(r).prompt('fix the bug in run').call('Grep', {'pattern': 'run'}, 'src/pkg/alpha.py:10: def run') + .call(MCP, {'name': 'Alpha.run'}, IMPACT_A).edit('src/pkg/alpha.py')) +check('a file an earlier grep showed is not credited', found(t, r) == [], found(t, r)) + +# --- the edit was reverted: what is left is a read, so Useful +r = repo() +t = T(r).prompt('fix the bug in run').call(MCP, {'name': 'Alpha.run'}, IMPACT_A).read('src/pkg/alpha.py').edit('src/pkg/alpha.py') +check('an edit no longer in the working tree is not Helped (Useful: it was read)', [x['label'] for x in found(t, r)] == ['Useful'], found(t, r)) + +# --- an answer nobody acted on prints nothing +r = repo() +t = T(r).prompt('fix the bug in run').call(MCP, {'name': 'Alpha.run'}, IMPACT_A) +check('an answer nothing came from is not a chain', found(t, r) == [], found(t, r)) + +# --- Redirected: two files dropped, the change lands in the one the answer showed +r = repo(); change(r, 'src/pkg/alpha.py') +t = (T(r).prompt('fix the bug in run').read('src/pkg/beta.py').read('src/pkg/gamma.py') + .call(MCP, {'name': 'Alpha.run'}, IMPACT_A).read('src/pkg/alpha.py').edit('src/pkg/alpha.py')) +c = found(t, r) +check('dropped files plus a change landing in the answer\'s file is Redirected', + [x['label'] for x in c] == ['Redirected'] and c[0]['from'] == ['src/pkg/beta.py', 'src/pkg/gamma.py'], c) + +# --- control: one of those files is touched again after the answer, and one file alone was looked at once +r = repo(); change(r, 'src/pkg/alpha.py') +t = (T(r).prompt('fix the bug in run').read('src/pkg/beta.py').read('src/pkg/gamma.py') + .call(MCP, {'name': 'Alpha.run'}, IMPACT_A).edit('src/pkg/alpha.py').read('src/pkg/gamma.py')) +c = found(t, r) +check('a file gone back to after the answer was not left behind (one dropped read is not a redirect)', + [x['label'] for x in c] == ['Helped'], c) + +# --- control: the earlier file is in the final diff, so the work did not move away from it +r = repo(); change(r, 'src/pkg/alpha.py'); change(r, 'src/pkg/beta.py') +t = (T(r).prompt('fix the bug in run').read('src/pkg/beta.py').read('src/pkg/beta.py') + .call(MCP, {'name': 'Alpha.run'}, IMPACT_A).edit('src/pkg/alpha.py')) +check('a file still in the diff was not left behind', [x['label'] for x in found(t, r)] == ['Helped'], found(t, r)) + +# --- picked tests: ran and passed is Helped; ran and failed is Useful +r = repo() +t = (T(r).prompt('which tests cover my change').call('mcp__plugin_axiomcode_axiomcode__tests', {}, TESTS_A) + .call('Bash', {'command': 'pytest tests/test_alpha.py -q'}, '1 passed')) +c = found(t, r) +check('picked tests that ran and passed are Helped', [x['label'] for x in c] == ['Helped'] and c[0]['tests_passed'] == 1, c) +check('the line names the picked test file and its result', 'test_alpha.py' in A.line(c[0]) and 'it passes' in A.line(c[0]), A.line(c[0])) +r = repo() +t = (T(r).prompt('which tests cover my change').call('mcp__plugin_axiomcode_axiomcode__tests', {}, TESTS_A) + .call('Bash', {'command': 'pytest tests/test_alpha.py -q'}, 'Exit code 1\n1 failed', error=True)) +r = repo() +t = (T(r).prompt('which tests cover my change').call('mcp__plugin_axiomcode_axiomcode__tests', {}, TESTS_A) + .call('Bash', {'command': 'pytest tests/test_alpha.py -q; echo done'}, '1 failed, 3 passed\ndone')) +check('a run that exits 0 but reports failures is not a pass', [x['label'] for x in found(t, r)] == ['Useful'], found(t, r)) +check('picked tests that failed are only Useful', [x['label'] for x in found(t, r)] == ['Useful'], found(t, r)) +PICK = lambda r: T(r).prompt('which tests cover my change').call('mcp__plugin_axiomcode_axiomcode__tests', {}, TESTS_A) +r = repo() +t = PICK(r).call('Bash', {'command': 'pytest tests/test_alpha.py -q 2>&1 | tail -3; ruff check .'}, + "ModuleNotFoundError: No module named 'trio'\nAll checks passed!") +check('a run that never started is no pass, though the line exits 0 and something else says passed', + [x['label'] for x in found(t, r)] == ['Useful'], found(t, r)) +t = (PICK(repo()).call('Bash', {'command': 'pytest tests/test_alpha.py -q'}, '1 failed, 2 passed') + .call('Bash', {'command': 'pytest tests/test_alpha.py -q'}, '3 passed')) +check('a test file passes when its latest run does: failed, then fixed', [x['label'] for x in found(t, t.root)] == ['Helped'], found(t, t.root)) +t = (PICK(repo()).call('Bash', {'command': 'pytest tests/test_alpha.py -q'}, '3 passed') + .call('Bash', {'command': 'pytest tests/test_alpha.py -q'}, '1 failed, 2 passed')) +check('passed, then broken, is no pass', [x['label'] for x in found(t, t.root)] == ['Useful'], found(t, t.root)) +TA = 'mcp__plugin_axiomcode_axiomcode__tests' +t = (PICK(repo()).prompt('one more change').call(TA, {}, TESTS_A).call('Bash', {'command': 'pytest tests/test_alpha.py -q'}, '3 passed')) +c = found(t, t.root) +check('a run goes to the latest answer that picked its tests', [(x['answer'], x['label']) for x in c] == [('toolu_002', 'Helped')], c) +t = PICK(repo()).call('Bash', {'command': 'pytest tests/test_alpha.py -q'}, '3 passed').prompt('again').call(TA, {}, TESTS_A) +c = found(t, t.root) +check('control: a run before the second answer stays with the first', [(x['answer'], x['label']) for x in c] == [('toolu_001', 'Helped')], c) + +# --- a test file named twice in one answer (bare, and with its directory) is one test file +r = repo() +t = (T(r).prompt('which tests cover my change').call('mcp__plugin_axiomcode_axiomcode__tests', {}, + TESTS_A + '\nrun: pytest test_alpha.py') + .call('Bash', {'command': 'pytest tests/test_alpha.py -q'}, '1 passed')) +c = found(t, r) +check('a test file named twice is counted once', c and c[0]['tests_ran'] == 1 and c[0]['tests'] == ['tests/test_alpha.py'], c) + +check('a test runner config beside the tests is not a test file', not A.TEST_FILE.search('test/jest.config.js') + and A.TEST_FILE.search('test/registry.test.ts') and A.TEST_FILE.search('tests/test_alpha.py')) + +# --- a test file the agent already knew of is not picked by an impact answer; a tests answer picks what it names +r = repo() +IMPACT_T = IMPACT_A + '\ntests:\n tests/test_alpha.py:5 test_run [test]' +t = (T(r).prompt('fix the bug in run').read('tests/test_alpha.py').call(MCP, {'name': 'Alpha.run'}, IMPACT_T) + .call('Bash', {'command': 'pytest tests/test_alpha.py -q'}, '1 passed')) +check('an impact answer is not credited for running a test file the agent had already opened', found(t, r, diff=False) == [], + found(t, r, diff=False)) +r = repo() +t = (T(r).prompt('fix the bug in run').call(MCP, {'name': 'Alpha.run'}, IMPACT_T) + .call('Bash', {'command': 'pytest tests/test_alpha.py -q'}, '1 passed')) +check('…but is when the answer showed it first', [x['label'] for x in found(t, r, diff=False)] == ['Useful'], found(t, r, diff=False)) + +# --- one shell line that writes a file and runs the tests, failing, still wrote the file +r = repo(); change(r, 'src/pkg/alpha.py') +t = (T(r).prompt('fix the bug in run').call(MCP, {'name': 'Alpha.run'}, IMPACT_A) + .call('Bash', {'command': "python3 - <<'EOF'\np='src/pkg/alpha.py'\ns=open(p).read()\nopen(p,'w').write(s)\nEOF\npytest -q"}, + 'Exit code 1', error=True)) +check('a write in a shell line that then fails its tests is still an edit', [x['label'] for x in found(t, r)] == ['Helped'], found(t, r)) +r = repo(); change(r, 'src/pkg/alpha.py') +t = (T(r).prompt('fix the bug in run').call(MCP, {'name': 'Alpha.run'}, IMPACT_A) + .call('Bash', {'command': "cat >> notes.jsonl <<'EOF'\n{\"file\": \"src/pkg/alpha.py\"}\nEOF"}, '')) +check('a shell line that only mentions a file (a log it appends to) did not edit it', found(t, r) == [], found(t, r)) + +# --- one test run is credited once, to the first answer that picked it +r = repo() +t = (T(r).prompt('which tests cover my change').call('mcp__plugin_axiomcode_axiomcode__tests', {}, TESTS_A) + .call('Bash', {'command': 'axiomcode tests --json /tmp/out.json'}, TESTS_A) + .call('Bash', {'command': 'pytest tests/test_alpha.py -q'}, '1 passed')) +c = found(t, r) +check('a test run two answers picked is credited once', len(c) == 1, c) +check('a flag or an output path is not shown as what was asked', A._q(dict(verb='tests', arg='--json /tmp/out.json')) == '`tests`', + A._q(dict(verb='tests', arg='--json /tmp/out.json'))) + +# --- the CLI through a shell counts as an answer; a shell line that also lists files does not +r = repo(); change(r, 'src/pkg/alpha.py') +t = (T(r).prompt('fix the bug in run').call('Bash', {'command': 'axiomcode impact Alpha.run 2>&1 | head -40'}, IMPACT_A) + .edit('src/pkg/alpha.py')) +c = found(t, r) +check('`axiomcode impact` in a shell is an answer, its argument without the redirection', + [x['label'] for x in c] == ['Helped'] and c[0]['arg'] == 'Alpha.run', c) +r = repo(); change(r, 'src/pkg/alpha.py') +t = (T(r).prompt('fix the bug in run').call('Bash', {'command': 'axiomcode impact Alpha.run; ls src/pkg'}, + 'no callers found\nsrc/pkg/alpha.py\nsrc/pkg/beta.py') + .edit('src/pkg/alpha.py')) +check('a shell line that also lists files credits nothing from the listing', found(t, r) == [], found(t, r)) +r = repo(); change(r, 'src/pkg/gamma.py') +t = (T(r).prompt('fix the bug in run').call('Bash', {'command': 'axiomcode impact Alpha.run; grep -rn run src'}, + 'src/pkg/gamma.py:3:run()\n' + IMPACT_A).edit('src/pkg/gamma.py')) +check('in a shell line that also greps, a file only grep printed is not credited', found(t, r) == [], found(t, r)) +r = repo(); change(r, 'src/pkg/alpha.py') +t = (T(r).prompt('fix the bug in run').call('Bash', {'command': 'axiomcode impact Alpha.run; grep -rn zzz src'}, + 'no match\n' + IMPACT_A).edit('src/pkg/alpha.py')) +check('…but a row of the answer in that line is', [x['label'] for x in found(t, r)] == ['Helped'], found(t, r)) + +# --- a bare file name (a listing, prose) is not a place the answer gave +r = repo(); change(r, 'src/pkg/alpha.py') +t = (T(r).prompt('fix the bug in run').call(MCP, {'name': 'Alpha.run'}, 'Alpha.run is defined in alpha.py') + .edit('src/pkg/alpha.py')) +check('a bare file name in an answer is not credited', found(t, r) == [], found(t, r)) + +# --- what a subagent saw is not the session's +r = repo(); change(r, 'src/pkg/alpha.py') +t = (T(r).prompt('fix the bug in run').call(MCP, {'name': 'Alpha.run'}, IMPACT_A, side=True).edit('src/pkg/alpha.py')) +check('an answer inside a subagent is not credited to the session', found(t, r) == [], found(t, r)) + +# --- the plugin's own hooks: a graph note put in front of the agent is an answer +NOTE = 'graph: after that command, 1 declaration(s) changed —\n reads / uses it (1): [resolved] Beta.go src/pkg/beta.py:3' +r = repo(); change(r, 'src/pkg/beta.py') +t = T(r).prompt('add a flag to run').edit('src/pkg/alpha.py').note(NOTE).edit('src/pkg/beta.py') +c = found(t, r) +check('the after-edit note that named a caller, then edited, is Helped', [(x['verb'], x['label'], x['edited']) for x in c] + == [('note', 'Helped', ['src/pkg/beta.py'])] and "after-edit check" in A.line(c[0]), c) +r = repo(); change(r, 'src/pkg/beta.py') +t = T(r).prompt('add a flag to run').note(NOTE, kind='hook_success', stdout=json.dumps({'additionalContext': NOTE})) \ + .note(NOTE).edit('src/pkg/beta.py') +check('the hook_success copy of a note does not make its files seen before the note', [x['verb'] for x in found(t, r)] == ['note'], found(t, r)) +r = repo(); change(r, 'src/pkg/beta.py') +t = T(r).prompt('add a flag to run').note('graph: where this task lands —\n src/pkg/beta.py (4 symbols)').edit('src/pkg/beta.py') +check('a note naming a file without a line (a summary) is not credited', found(t, r) == [], found(t, r)) +r = repo(); change(r, 'src/pkg/beta.py') +t = T(r).prompt('add a flag to run').note('reminder: src/pkg/beta.py:3 was opened').edit('src/pkg/beta.py') +check('an attachment that is not a graph note is no answer', found(t, r) == [], found(t, r)) + +# --- the task already named what a row is about: that row is the task's lead, not the answer's +ROWS = '1. src/pkg/beta.py:3 [resolved · Beta.go]\n2. src/pkg/gamma.py:7 [resolved · Gamma.stop]' +r = repo(); change(r, 'src/pkg/beta.py'); change(r, 'src/pkg/gamma.py') +t = (T(r).prompt('fix `Beta.go` so it stops').call(MCP, {'name': 'Alpha.run'}, ROWS) + .edit('src/pkg/beta.py').edit('src/pkg/gamma.py')) +r2 = repo(); change(r2, 'src/pkg/beta.py') +t2 = T(r2).prompt('`getBeta` returns the wrong value').call(MCP, {'name': 'Alpha.run'}, '1. src/pkg/beta.py:3 [resolved · getBeta]').edit('src/pkg/beta.py') +check('a camelCase name the task gave is the task\'s lead too', found(t2, r2) == [], found(t2, r2)) +check('a row about a name the task gave is not credited; the other row is', [x['edited'] for x in found(t, r)] == [['src/pkg/gamma.py']], found(t, r)) +r = repo(); change(r, 'src/pkg/beta.py') +t = (T(r).prompt('add a flag to `Alpha.run`').call(MCP, {'name': 'Alpha.run'}, + '1. src/pkg/beta.py:3 [resolved · Beta.go]\n → 3 self.run() # Alpha.run') + .edit('src/pkg/beta.py')) +check('the name asked about, on its callers\' lines, does not hide them', [x['edited'] for x in found(t, r)] == [['src/pkg/beta.py']], found(t, r)) + +# --- an edit in a shell line that also asks the graph, and an edit through a helper the script defines +r = repo(); change(r, 'src/pkg/alpha.py') +t = (T(r).prompt('fix the bug in run').call(MCP, {'name': 'Alpha.run'}, IMPACT_A) + .call('Bash', {'command': "sed -i '' 's/a/b/' src/pkg/alpha.py; axiomcode tests"}, '1. tests/test_alpha.py:5 [test]')) +check('an edit in a line that also runs axiomcode is still an edit', [x['label'] for x in found(t, r) if x['verb'] == 'impact'] == ['Helped'], found(t, r)) +HELPER = ("python3 - <<'EOF'\nimport pathlib\ndef sub(path, old, new):\n p = pathlib.Path(path); s = p.read_text()\n" + " p.write_text(s.replace(old, new))\nsub('src/pkg/alpha.py', 'x', 'y')\nprint(open('src/pkg/gamma.py').read())\nEOF") +r = repo(); change(r, 'src/pkg/alpha.py'); change(r, 'src/pkg/gamma.py') +check('a helper that writes its path argument writes the files it is called with, not the ones only read', + A.written(HELPER, r) == {'src/pkg/alpha.py'}, A.written(HELPER, r)) + +# --- the ledger: printed once; printed again only when it grows +r = repo(); change(r, 'src/pkg/alpha.py') +t = T(r).prompt('fix the bug in run').call(MCP, {'name': 'Alpha.run'}, IMPACT_A).read('src/pkg/alpha.py') +p = t.save() +subprocess.run(['git', '-C', r, 'checkout', '-q', '--', '.']) +first = A.at_stop(p, r, 's1') +again = A.at_stop(p, r, 's1') +change(r, 'src/pkg/alpha.py'); t.edit('src/pkg/alpha.py'); p = t.save() +grown = A.at_stop(p, r, 's1') +check('a chain prints once (Useful)', 'Useful' in first and again == '', (first, again)) +check('it prints again when it grows a stronger label', 'Helped' in grown, grown) +check('the ledger keeps both', len(A._load(r)) == 2, A._load(r)) +out, n = A.report(r) +page = open(out).read() +check('the report renders the session\'s chains', n == 1 and 'Helped' in page and 'impact Alpha.run' in page, (out, n)) + +# --- calls sent in one message go out before any answer comes back: a file read alongside the query was not shown by it +r = repo(); change(r, 'src/pkg/alpha.py') +t = (T(r).prompt('fix the bug').call(MCP, {'name': 'Alpha.run'}, IMPACT_A, mid='m1') + .call('Bash', {'command': 'cat src/pkg/alpha.py'}, 'contents', mid='m1').edit('src/pkg/alpha.py')) +got = A.chains(A.steps(t.save()), r) +check('a file read in the same message as the query is not credited to its answer', not got, got) +t = (T(r).prompt('fix the bug').call(MCP, {'name': 'Alpha.run'}, IMPACT_A, mid='m1') + .call('Bash', {'command': 'cat src/pkg/alpha.py'}, 'contents', mid='m2').edit('src/pkg/alpha.py')) +got = A.chains(A.steps(t.save()), r) +check('control: the same read in the next message is', [c['label'] for c in got] == ['Helped'], got) + +# --- the hook end to end: a Stop event in, the receipt handed back to the agent, which ends its reply with it; when +# the reply does not carry it, the second stop shows it as a message instead; switched off, nothing +def hook(ev, **env): + h = subprocess.run([sys.executable, os.path.join(HOOKS, 'assist.py')], input=json.dumps(ev), capture_output=True, + text=True, env=dict(os.environ, **env)) + return h, (json.loads(h.stdout) if h.stdout.strip() else {}) + + +def handed_back(t, reason, reply): + t.lines.append({'type': 'user', 'isMeta': True, 'message': {'role': 'user', 'content': 'Stop hook feedback:\n' + reason}}) + t.lines.append({'type': 'assistant', 'message': {'role': 'assistant', 'content': [{'type': 'text', 'text': reply}]}}) + return t.save() + + +r = repo(); change(r, 'src/pkg/alpha.py') +t = T(r).prompt('fix the bug in run').call(MCP, {'name': 'Alpha.run'}, IMPACT_A).edit('src/pkg/alpha.py') +ev = {'hook_event_name': 'Stop', 'session_id': 's2', 'transcript_path': t.save(), 'cwd': r, 'stop_hook_active': False} +h, out = hook(ev) +reason = out.get('reason', '') +msg = reason[reason.find('◆ AxiomCode'):] +check('the Stop hook hands the receipt back to the agent to end its reply with', h.returncode == 0 and out.get('decision') == 'block' + and msg.startswith('◆ AxiomCode') and 'Helped' in msg and 'exactly as written' in reason, (h.returncode, h.stdout, h.stderr)) +check('the Stop hook rebuilds the report and links it', 'report: file://' in msg and os.path.isfile(os.path.join(r, '.axiomcode', 'assists.html')), msg) +check('it counts the answers that led the work, never those that did not', 'acted on 1 answer' in msg and ' of ' not in msg.splitlines()[0], msg) +h2, out2 = hook(dict(ev, stop_hook_active=True, transcript_path=handed_back(t, reason, 'Done.\n\n```\n' + msg + '\n```'))) +check('a reply that quotes the receipt ends the turn with nothing more', h2.returncode == 0 and out2 == {}, h2.stdout) +check('the hand-back is not a prompt, so a later turn still credits by the task alone', + [s['kind'] for s in A.steps(t.save())].count('prompt') == 1, A.steps(t.save())) +r = repo(); change(r, 'src/pkg/alpha.py') +t = T(r).prompt('fix the bug in run').call(MCP, {'name': 'Alpha.run'}, IMPACT_A).edit('src/pkg/alpha.py') +ev = dict(ev, session_id='s5', transcript_path=t.save(), cwd=r) +h, out = hook(ev) +reason = out.get('reason', '') +msg = reason[reason.find('◆ AxiomCode'):] +h2, out2 = hook(dict(ev, stop_hook_active=True, transcript_path=handed_back(t, reason, 'AxiomCode helped a lot here.'))) +check('a reply that drops or rewrites the receipt gets it shown as a message instead', out2.get('systemMessage') == msg, (h2.stdout, msg)) +h3, out3 = hook(dict(ev, stop_hook_active=True)) +check('the second stop happens once: a third is silent', out3 == {}, h3.stdout) + +# --- two answers, one acted on: the count says so; a later turn that only grows an earlier chain says that instead +r = repo(); change(r, 'src/pkg/alpha.py') +t = (T(r).prompt('fix the bug in run').call(MCP, {'name': 'Alpha.run'}, IMPACT_A) + .call(MCP, {'name': 'Gamma.go'}, 'no callers found').read('src/pkg/alpha.py')) +first = A.at_stop(t.save(), r, 's4') +t.prompt('now fix it').edit('src/pkg/alpha.py') +later = A.at_stop(t.save(), r, 's4') +check('one of two answers acted on counts only the one', 'acted on 1 answer' in first and ' of 2' not in first, first) +check('a turn that only grows an earlier chain names it as earlier', 'acted on 1 answer from an earlier turn' in later.splitlines()[0] and 'Helped' in later, later) +page = open(A.report(r)[0]).read() +check('the report has no unfilled template text', '\\1' not in page and "{'" not in page and "'}" not in page and ' if ' not in page, page[:300]) +check('the report counts answers asked and the files and tests behind them', ' of 2' not in page and 'Previously unseen files edited' in page and 'edited' in page + and 'Featured evidence' in page and 'Measured from agent actions' in page, page[:300]) +h, _ = hook(dict(ev, session_id='s3'), AXIOMCODE_ASSISTS='off') +check('AXIOMCODE_ASSISTS=off prints nothing', h.returncode == 0 and not h.stdout.strip(), h.stdout) +h = subprocess.run([sys.executable, os.path.join(HOOKS, 'assist.py')], input='{"hook_event_name":"Stop"}', capture_output=True, text=True) +check('a Stop event with no transcript is silent and never fails', h.returncode == 0 and not h.stdout.strip(), (h.returncode, h.stdout)) + +print(f'\n{len(fails)} failed' if fails else '\nall passed') +sys.exit(1 if fails else 0) diff --git a/tests/front_door.py b/tests/front_door.py index a281856d5..efb5310b7 100644 --- a/tests/front_door.py +++ b/tests/front_door.py @@ -5,7 +5,7 @@ every place with the code of the function it sits in, in a fenced block. That shape is given at the front doors — the installed command (bin/axiomcode sets AXIOMCODE_FRONT) and the MCP server (AXIOMCODE_SURFACE=mcp) — when no flag is passed. Everything that calls the dispatcher directly (the hooks, the case suite, loops) or passes a flag gets the verb's -own answer, unchanged; a verb outside the surface (find, context, graph, diff, install) is refused by the installed +own answer, unchanged; a verb outside the surface (find, diff, install) is refused by the installed command. a. bin/axiomcode on a small repository (copied to a temporary directory, committed, indexed): impact and @@ -98,11 +98,14 @@ def main(): if fails: return 1 # ── a. the installed command, no flags ─────────────────────────────────────────────────────────────────── - for verb, args in (('find', ('how is the invoice total computed',)), - ('graph', ()), ('diff', ()), ('install', ())): + for verb, args in (('find', ('how is the invoice total computed',)), ('diff', ()), ('install', ())): rc, out, err = cli(repo, verb, *args) check(f'{verb}: off the surface, the installed command refuses it and names the supported verbs', rc != 0 and 'impact' in err and 'path' in err and 'tests' in err, (out + err)[:400]) + rc, out, err = cli(repo, 'graph') + page = out.strip().splitlines()[-1].split('page: ', 1)[-1] if out.strip() else '' + check('graph: on the surface, it draws the page and prints its path last', rc == 0 and page.endswith('graph.html') + and os.path.isfile(page), (out + err)[-400:]) rc, out, err = cli(repo, 'context', 'how is the invoice total computed') check('context: on the surface, the task in words answers with the flow', rc == 0 and 'pricing.py' in out, (out + err)[:400]) rc, out, err = cli(repo, 'impact', 'vat_rate') diff --git a/tests/graph_verb.py b/tests/graph_verb.py new file mode 100644 index 000000000..594cf3878 --- /dev/null +++ b/tests/graph_verb.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +"""tests/graph_verb.py — `axiomcode graph` draws the graph the index built, and rebuilds it only as it was indexed. + +It used to run axiomcode-build with no flags. That detects every language in the tree, so on a repository indexed with +--lang python the build never matched the recorded graph: every call rebuilt it from scratch in every language +present, a JavaScript or TypeScript compile for a few stray files included, and left a graph for each of them that +later queries answered from. Its output was a raw Python dict and a relative ../../ page path. + +Two throwaway repositories: + + indexed Python under app/, with a stray JavaScript file beside it, indexed with --lang python --src app + current the page is drawn from the graph: no engine run (the graph file is untouched), no graph for another + language, prose counts and the page's absolute path + stale after an edit the graph is rebuilt with --lang python --src app, the page shows the edit, and still no + JavaScript graph appears + control Python and Java, indexed with no --lang (every language): the page draws both, and both graphs stay; + detected languages are not pinned, so a rebuild still detects them + rebuilds C# with a stray TypeScript front end, indexed with --lang csharp: an edit plus a commit (the background + refresh), a bare `index` and a query that repairs a broken graph each solve C# alone + surfaces `axiomcode help graph` and the MCP tool's description describe this, not `axiomcode-graph build` + + python3 tests/graph_verb.py [-v] +""" +import os, shutil, subprocess, sys, tempfile, time + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +AX = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts', 'axiomcode') +MCP = os.path.join(ROOT, 'plugins', 'axiomcode', 'mcp', 'server.py') +FRESH = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts', 'ax_fresh.py') + +INDEXED = { + 'app/shop/__init__.py': '', + 'app/shop/orders.py': 'def price(n):\n return n * 3\n\n\ndef order_total(n):\n return price(n) + 1\n', + 'app/shop/cli.py': 'from shop.orders import order_total\n\n\ndef main():\n return order_total(2)\n', + 'app/static/widget.js': 'function strayWidget(a) {\n return a + 1\n}\n\nmodule.exports = { strayWidget }\n', + 'tools/other.py': 'def outside_src():\n return 1\n', +} +CSHARP = { + 'App/App.csproj': '\n net8.0\n\n', + 'App/Orders.cs': 'namespace App;\n\npublic class OrderService\n{\n public decimal Total(int widgets) => Price(widgets) * 2;\n\n private decimal Price(int widgets) => widgets * 3m;\n}\n', + 'App/ClientApp/tsconfig.json': '{ "compilerOptions": { "strict": true } }\n', + 'App/ClientApp/main.ts': 'export function strayFrontEnd(): number {\n return 1\n}\n', +} +MIXED = { + 'pom.xml': '\n 4.0.0\n example\n mixed\n 1.0\n\n', + 'src/main/java/app/Billing.java': 'package app;\n\npublic class Billing {\n public int chargeAccount(int n) {\n return fee(n) + n;\n }\n\n int fee(int n) {\n return 2;\n }\n}\n', + 'src/main/java/app/Invoice.java': 'package app;\n\npublic class Invoice {\n public int issueInvoice() {\n return new Billing().chargeAccount(3);\n }\n}\n', + 'scripts/report/__init__.py': '', + 'scripts/report/make.py': 'def render_report(x):\n return str(x)\n\n\ndef main():\n return render_report(1)\n', +} + + +def sh(cwd, *cmd, env=None): + return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, env=env) + + +def make(root, files): + for rel, text in files.items(): + os.makedirs(os.path.dirname(os.path.join(root, rel)), exist_ok=True) + open(os.path.join(root, rel), 'w').write(text) + for cmd in (('git', 'init', '-q'), ('git', 'add', '-A'), ('git', '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'base')): + sh(root, *cmd) + + +def main(argv): + verbose = '-v' in argv + fails = [] + + def check(ok, why, detail=''): + print(('ok ' if ok else 'FAIL ') + why + ('' if ok and not verbose or not detail else '\n ' + detail.strip()[-1500:].replace('\n', '\n '))) + if not ok: fails.append(why) + + work = os.path.realpath(tempfile.mkdtemp(prefix='axiomcode-graphverb-')) # macOS: /var is /private/var, and the page is named by its real path + # the engine is this checkout's, unless the caller names a built one (a worktree whose parser is not built) + env = dict(os.environ, AXIOMCODE_ENGINE=os.environ.get('AXIOMCODE_ENGINE') or ROOT, AXIOMCODE_NO_REFRESH='1') + for k in ('AXIOMCODE_LANG', 'AXIOMCODE_SRC', 'AXIOMCODE_LIBRARY', 'AXIOMCODE_GRAPH'): env.pop(k, None) + try: + # ── indexed with --lang python --src app ────────────────────────────────────────────────────────────── + repo = os.path.join(work, 'indexed'); make(repo, INDEXED) + ax = os.path.join(repo, '.axiomcode'); db = os.path.join(ax, 'out', 'graph.sqlite') + b = sh(repo, 'bash', AX, 'index', repo, '--lang', 'python', '--src', 'app', env=env) + check(b.returncode == 0 and os.path.exists(db), 'setup: the repository indexes with --lang python --src app', b.stdout + b.stderr) + if b.returncode: return 1 + before = (os.path.realpath(db), os.stat(os.path.realpath(db)).st_mtime_ns) + + t0 = time.time(); g = sh(repo, 'bash', AX, 'graph', repo, env=env); took = time.time() - t0 + page = os.path.join(ax, 'graph', 'graph.html') + check(g.returncode == 0 and os.path.exists(page), 'current: the page is written', g.stdout + g.stderr) + check((os.path.realpath(db), os.stat(os.path.realpath(db)).st_mtime_ns) == before and 'no rebuild' in g.stdout and 'building' not in g.stdout, + f'current: the page is drawn from the graph there, the engine does not run ({took:.1f} s)', g.stdout + g.stderr) + check(not os.path.exists(os.path.join(ax, 'lang')) and not os.path.exists(os.path.join(ax, 'out', 'javascript')), + 'current: no graph appears for the stray JavaScript file the index left out', g.stdout) + check(f'page: {page}' in g.stdout and '../' not in g.stdout and "{'" not in g.stdout and ' functions and methods' in g.stdout, + 'current: the output is prose, and names the page by its absolute path', g.stdout) + check('axiomcode-graph build' not in g.stdout + g.stderr, 'current: it advises no command an agent does not have', g.stdout + g.stderr) + html = open(page).read() if os.path.exists(page) else '' + check('order_total' in html and 'strayWidget' not in html and 'outside_src' not in html, + 'current: the page holds the indexed Python and nothing outside --lang / --src', '') + + # an edit: the graph is stale, and is rebuilt as it was indexed + with open(os.path.join(repo, 'app', 'shop', 'orders.py'), 'a') as f: f.write('\n\ndef refund_order(n):\n return -order_total(n)\n') + # read-only first: --no-refresh (and AXIOMCODE_NO_REFRESH=1, which this env sets) draws the stale graph as it is + stamp = (os.path.realpath(db), os.stat(os.path.realpath(db)).st_mtime_ns) + g = sh(repo, 'bash', AX, 'graph', repo, '--no-refresh', env=env) + check(g.returncode == 0 and 'refresh off (--no-refresh): drawn from the graph as it is' in g.stdout + and 'predates edits to 1 file(s)' in g.stdout and (os.path.realpath(db), os.stat(os.path.realpath(db)).st_mtime_ns) == stamp, + 'stale: --no-refresh draws the graph as it is, says it is out of date, and never rebuilds it', g.stdout + g.stderr) + # the near-miss: asked without it (and with no AXIOMCODE_NO_REFRESH), the stale graph is rebuilt as it was indexed + g = sh(repo, 'bash', AX, 'graph', repo, env={k: v for k, v in env.items() if k != 'AXIOMCODE_NO_REFRESH'}) + html = open(page).read() if os.path.exists(page) else '' + check(g.returncode == 0 and 'refund_order' in html and 'rebuilt (--lang python --src app)' in g.stdout, + 'stale: the graph is rebuilt with the --lang and --src it was indexed with, and the page shows the edit', g.stdout + g.stderr) + check(not os.path.exists(os.path.join(ax, 'lang')) and 'building python graph' in g.stdout and 'javascript graph' not in g.stdout and '+ javascript' not in g.stdout, + 'stale: the rebuild solves Python alone; no JavaScript graph appears', g.stdout) + check('outside_src' not in html, 'stale: the rebuild keeps --src (a file outside it is not drawn)', '') + # the rebuild is a refresh: the baseline stays HEAD's tree, so the edit is still an edit to `changed`/`test-impact` + c = sh(repo, 'bash', AX, 'changed', repo, env=env); ti = sh(repo, 'bash', AX, 'test-impact', repo, env=env) + check('refund_order' in c.stdout and 'baseline moved' not in c.stdout + ti.stdout, + 'stale: the rebuild keeps the baseline: `changed` still names the edit, and nothing says the baseline moved', + c.stdout + c.stderr + ti.stdout) + # control: an explicit index of the edited tree DOES move the baseline (#1222), and now says so where the edit vanished + with open(os.path.join(repo, 'app', 'shop', 'orders.py'), 'a') as f: f.write('\n\ndef void_order(n):\n return 0\n') + b = sh(repo, 'bash', AX, 'index', repo, '--lang', 'python', '--src', 'app', env=env) + c = sh(repo, 'bash', AX, 'changed', repo, env=env); ti = sh(repo, 'bash', AX, 'test-impact', repo, env=env) + check(b.returncode == 0 and 'refund_order' not in c.stdout and 'void_order' not in c.stdout + and 'baseline moved' in c.stdout and 'shop/orders.py' in c.stdout and 'axiomcode index' in c.stdout and 'baseline moved' in ti.stdout, + 'control: an explicit index of an edited tree moves the baseline, and `changed` and `test-impact` say so, naming the file', + b.stdout[-300:] + c.stdout + c.stderr + ti.stdout) + + # ── control: indexed with no --lang, every language present ────────────────────────────────────────── + mixed = os.path.join(work, 'mixed'); make(mixed, MIXED) + b = sh(mixed, 'bash', AX, 'index', mixed, env=env) + mx = os.path.join(mixed, '.axiomcode') + both = [os.path.exists(os.path.join(mx, 'out', 'graph.sqlite')), os.path.exists(os.path.join(mx, 'lang', 'python', 'out', 'graph.sqlite'))] + check(b.returncode == 0 and all(both), 'control: a Java and Python repository indexed with no --lang has both graphs', b.stdout + b.stderr) + g = sh(mixed, 'bash', AX, 'graph', mixed, env=env) + mpage = os.path.join(mx, 'graph', 'graph.html') + html = open(mpage).read() if os.path.exists(mpage) else '' + check(g.returncode == 0 and 'chargeAccount' in html and 'render_report' in html and any(l.startswith('graph of ') and 'java' in l and 'python' in l for l in g.stdout.splitlines()), + 'control: the page draws every language the repository was indexed in', g.stdout + g.stderr) + check(os.path.exists(os.path.join(mx, 'lang', 'python', 'out', 'graph.sqlite')) and 'no rebuild' in g.stdout, + 'control: drawing it keeps both graphs and rebuilds neither', g.stdout) + c = sh(mixed, sys.executable, FRESH, 'chosen', mixed, env=env) + check(c.returncode == 0 and c.stdout.strip() == '', 'control: languages that were detected are not pinned; a rebuild detects them again', c.stdout) + + # ── every rebuild path: C# indexed with --lang csharp, a stray TypeScript front end beside it ───────────── + cs = os.path.join(work, 'csharp'); make(cs, CSHARP); cx = os.path.join(cs, '.axiomcode') + b = sh(cs, 'bash', AX, 'index', cs, '--lang', 'csharp', env=env) + check(b.returncode == 0 and os.path.exists(os.path.join(cx, 'out', 'graph.sqlite')) and not os.path.exists(os.path.join(cx, 'lang')), + 'rebuilds: a C# repository indexes with --lang csharp and no TypeScript graph', b.stdout + b.stderr) + c = sh(cs, sys.executable, FRESH, 'chosen', cs, env=env) + check(c.stdout.strip() == 'csharp', 'rebuilds: the file table records csharp as chosen', c.stdout) + # an edit, committed, then the background refresh: the path a hook or the MCP server's timer starts + with open(os.path.join(cs, 'App', 'Orders.cs'), 'a') as f: f.write('\n// committed edit\n') + sh(cs, 'git', '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qam', 'edit') + w = sh(cs, sys.executable, FRESH, 'worker', cs, env=dict(env, AXIOMCODE_REFRESH_DEBOUNCE='0.3')) + stamp = lambda: open(os.path.join(cx, 'out', 'stamp')).read().strip() if os.path.exists(os.path.join(cx, 'out', 'stamp')) else '' + no_ts = lambda: not os.path.exists(os.path.join(cx, 'lang')) and not os.path.exists(os.path.join(cx, 'out', 'typescript')) + check(w.returncode == 0 and 'rebuilding' in w.stdout and '-csharp-' in stamp() and no_ts(), + 'rebuilds: an edit plus a commit refreshes C# alone; no TypeScript graph, no TypeScript solve', w.stdout + stamp()) + # a bare index, as an agent re-runs one "to make sure" + b = sh(cs, 'bash', AX, 'index', cs, env=env) + check(b.returncode == 0 and 'keeping the languages this graph was indexed with (--lang csharp)' in b.stdout and '-csharp-' in stamp() and no_ts(), + 'rebuilds: a bare index keeps --lang csharp and says so', b.stdout + b.stderr) + # a query that finds the graph pointer broken repairs it, with the same languages + ptr = os.path.join(cx, 'out', 'graph.sqlite'); tgt = os.path.realpath(ptr) + os.rename(tgt, tgt + '.gone') + q = sh(cs, 'bash', AX, 'impact', 'OrderService.Total', cs, env=env) + check(q.returncode == 0 and os.path.exists(ptr) and '-csharp-' in stamp() and no_ts(), + 'rebuilds: a query repairing a broken graph builds C# alone', q.stdout[-600:] + q.stderr[-600:]) + + # ── surfaces ────────────────────────────────────────────────────────────────────────────────────────── + h = sh(ROOT, 'bash', AX, 'help', 'graph', env=env) + check(h.returncode == 0 and 'axiomcode graph []' in h.stdout and 'axiomcode-graph build' not in h.stdout and 'as it was indexed' in h.stdout, + '`axiomcode help graph` names the verb agents call and says a stale graph is rebuilt as it was indexed', h.stdout) + # graph is internal: not an MCP tool (tests/surfaces.py holds the list of public verbs) + check('def graph(' not in open(MCP).read(), 'graph is not offered as an MCP tool', '') + finally: + shutil.rmtree(work, ignore_errors=True) + print(f"\n{'FAIL' if fails else 'ok'}: {len(fails)} of the checks above failed" if fails else '\nok: every check passed') + return 1 if fails else 0 + + +if __name__ == '__main__': + sys.exit(main(sys.argv)) diff --git a/tests/surfaces.py b/tests/surfaces.py index c4eb20ab8..09899d2b3 100644 --- a/tests/surfaces.py +++ b/tests/surfaces.py @@ -29,8 +29,10 @@ MCP = os.path.join(PLUG, 'mcp', 'server.py') CLI = os.path.join(ROOT, 'bin', 'axiomcode') # the command an install puts on $PATH -PUBLIC = ['index', 'impact', 'path', 'tests', 'context', 'link'] -NO_MCP = {'index': 'setup, not a question: the first query through the MCP server builds the graph itself'} +PUBLIC = ['index', 'impact', 'path', 'tests', 'context', 'link', 'report', 'graph'] +NO_MCP = {'index': 'setup, not a question: the first query through the MCP server builds the graph itself', + 'report': 'a page for the user, not a question for the agent: the end-of-turn receipt already reaches both', + 'graph': 'a page for the user to look at, not an answer for the agent'} # dispatched, not advertised: verb -> why INTERNAL = { 'build': 'the old name of index',