From e7e599f53980a7dcdb42f7b08b187887310ee534 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:39:11 -0700 Subject: [PATCH] plugin: axiomcode value , the current value of a named constant on the CLI and as an MCP tool The index has stored each literal's value, kind and, for a named constant, its name (literals.name) since v8, in Python and TypeScript among others, but no verb read it: "what is the current value of limit X?" was answered by querying graph.sqlite by hand. `value ` prints the value, its kind and file:line with the line's source. - exact name first, then every declaration whose last part it is (MAX_ITEMS and Pager.MAX_ITEMS); a name defined in several places is listed in all of them with the count, never one picked - Class.NAME answers for that member; MAX_* / Pager.* list every match (a GLOB prefilter, cheap) - a name declared without a single literal (a call, a dict or object) prints its declaration line and says so; a local says it is a local and where it is assigned; a function or type says it is not a constant; an unknown name says so and exits 1 - every language graph of the repository is asked; each row stays in its own graph, nothing is joined - runs through ax_fresh like the other query verbs: stale rows are marked, and a line that no longer writes the graph's value says so; --json is one document MCP tool value(name) beside context/impact/path/link/tests; help, both SKILL.md copies, AGENTS.md, rules/axiomcode.mdc and the README list it. tests/surfaces.py, tests/mcp.py and tests/mcp_docs.py know the new tool; mcp_docs.py's tool list was stale on the base (it still expected three tools and did not read context/link, 5 failures before this). The README's CLI section no longer names --fresh, which failed surfaces.py on the base. skills/axiomcode/SKILL.md had drifted from its plugin copy (no HTML + CSS line), which failed manifests.py. Tests: tests/cases/{python,typescript}/value-verb (10 checks each, 0/20 before, 20/20 after), including the control that a constant initialized through a call is given no value. Refs #1835 --- README.md | 7 +- plugins/axiomcode/AGENTS.md | 4 +- plugins/axiomcode/mcp/server.py | 8 + plugins/axiomcode/rules/axiomcode.mdc | 4 +- plugins/axiomcode/skills/axiomcode/SKILL.md | 16 +- .../skills/axiomcode/scripts/ax_fresh.py | 2 +- .../skills/axiomcode/scripts/axiomcode | 12 +- .../skills/axiomcode/scripts/axiomcode-value | 245 ++++++++++++++++++ skills/axiomcode/SKILL.md | 16 +- tests/cases/python/value-verb/case.json | 38 +++ tests/cases/python/value-verb/src/limits.py | 19 ++ tests/cases/python/value-verb/src/other.py | 2 + tests/cases/typescript/value-verb/case.json | 38 +++ .../cases/typescript/value-verb/src/limits.ts | 19 ++ .../cases/typescript/value-verb/src/other.ts | 2 + tests/mcp.py | 11 +- tests/mcp_docs.py | 4 +- tests/surfaces.py | 2 +- 18 files changed, 428 insertions(+), 21 deletions(-) create mode 100755 plugins/axiomcode/skills/axiomcode/scripts/axiomcode-value create mode 100644 tests/cases/python/value-verb/case.json create mode 100644 tests/cases/python/value-verb/src/limits.py create mode 100644 tests/cases/python/value-verb/src/other.py create mode 100644 tests/cases/typescript/value-verb/case.json create mode 100644 tests/cases/typescript/value-verb/src/limits.ts create mode 100644 tests/cases/typescript/value-verb/src/other.ts diff --git a/README.md b/README.md index ba0d1d453..4df59cdbf 100644 --- a/README.md +++ b/README.md @@ -309,7 +309,7 @@ about one change had to read 95 of 43,793 methods, and every true direct caller ## CLI commands Three questions, each answered as numbered places with the code of the function each one sits in. The MCP server -offers the same three as tools: `impact(name)`, `path(start, end)` and `tests()`. Finding where code lives is +offers the same as tools: `impact(name)`, `path(start, end)`, `tests()` and `value(name)`. Finding where code lives is left to your own search: bring the name you found to these commands. | command | what it answers | @@ -318,6 +318,7 @@ left to your own search: bring the name you found to these commands. | `axiomcode impact` | the same for the declarations your uncommitted edits changed; the answer starts with `your edits:` | | `axiomcode path ` | how A reaches B: every hop of the call chain, with the code at each call | | `axiomcode tests` | the tests your uncommitted edits reach, and a last `run:` line with the command that runs them | +| `axiomcode value ` | the current value of a named constant, its kind and the `file:line` that writes it; `Pager.MAX_ITEMS` for a class's, `MAX_*` for every match. A name defined in several places is listed in all of them | | `axiomcode link ` | record where a call the graph could not resolve lands (kept in `axiomcode-links.tsv`); impact, path and tests then walk it, labelled `[asserted]`. Alone, lists the links and whether each was applied | | `axiomcode index` | build the graph explicitly (the first query builds it too); `--lang`, `--src` and `--library` narrow it | @@ -337,8 +338,8 @@ edit, a shell command, a finished turn, at session start, and before a query, an background rebuild per repository, with the language, `--src` and `--library` of the graph it replaces. Every command keeps reading the previous graph until the new one is indexed and swapped in. A query answers from the previous graph at once, with a `graph refresh:` line naming the files it predates and the rows in them marked; -`--fresh` waits for the rebuild instead, and `AXIOMCODE_FRESH_WAIT` (seconds, default 0) lets every query wait -that long. The MCP server also checks every repository it has answered for once 15 minutes have +`AXIOMCODE_FRESH_WAIT` (seconds, default 0) lets every query wait +that long for the rebuild instead. The MCP server also checks every repository it has answered for once 15 minutes have passed since its last update (`AXIOMCODE_REFRESH_INTERVAL`, seconds; 0 turns it off), which catches edits made while a session sits idle. The graph records when and why it was built in `index_meta` (`refreshed_at`, `refresh_reason`). `AXIOMCODE_NO_REFRESH=1` turns the rebuilds off, not the check: an answer from a graph older than an edit still diff --git a/plugins/axiomcode/AGENTS.md b/plugins/axiomcode/AGENTS.md index 4d8b8abb9..287af7753 100644 --- a/plugins/axiomcode/AGENTS.md +++ b/plugins/axiomcode/AGENTS.md @@ -8,13 +8,15 @@ that never spell the name: impact() with no name: the same for your uncommitted edits path(start, end) how A reaches B, every hop of the call chain tests() the tests your uncommitted edits reach, and the command that runs them + value(name) the current value of a named constant (MAX_ITEMS, Pager.MAX_ITEMS, MAX_*), + with its kind and the file:line that writes it link(site, target) record where an unresolved call lands, when the code makes it certain; impact, path and tests then walk it, labelled [asserted] context(task) how something works, as a narrative: the call flow step by step; context(task, source=True) carries each step's code Without the tools, the same from the shell: `axiomcode impact `, -`axiomcode path `, `axiomcode tests`, `axiomcode context "" --source`. +`axiomcode path `, `axiomcode tests`, `axiomcode value `, `axiomcode context "" --source`. Every answer is a numbered list of places, each with the code of the function it sits in and the line that matters marked `→`: answer from that code, and open a file only where a body was cut. A `resolved` place has diff --git a/plugins/axiomcode/mcp/server.py b/plugins/axiomcode/mcp/server.py index 27c4c3574..2535988b4 100755 --- a/plugins/axiomcode/mcp/server.py +++ b/plugins/axiomcode/mcp/server.py @@ -255,6 +255,14 @@ def path(start: str, end: str) -> str: written on. start / end as written in the code (Owner.method, function, Type).""" return plain(run(['path', start, end, os.getcwd()])) +@srv.tool() +def value(name: str) -> str: + """The current value of a named constant: its value, its kind (number, string, bool) and the file:line that writes + it, with the line. name as written in the code: MAX_ITEMS, Pager.MAX_ITEMS for a class's, MAX_* for every match. A + name defined in several places is listed in all of them; a name declared without a literal value, a local, or an + unknown name says which.""" + return plain(run(['value', name, os.getcwd()])) + @srv.tool() def link(site: str = '', target: str = '') -> str: """Record where an unresolved call lands, when you have read the code and the target is CERTAIN: site is the call's diff --git a/plugins/axiomcode/rules/axiomcode.mdc b/plugins/axiomcode/rules/axiomcode.mdc index d36b30c5a..fc9099a51 100644 --- a/plugins/axiomcode/rules/axiomcode.mdc +++ b/plugins/axiomcode/rules/axiomcode.mdc @@ -13,13 +13,15 @@ that never spell the name: impact() with no name: the same for your uncommitted edits path(start, end) how A reaches B, every hop of the call chain tests() the tests your uncommitted edits reach, and the command that runs them + value(name) the current value of a named constant (MAX_ITEMS, Pager.MAX_ITEMS, MAX_*), + with its kind and the file:line that writes it link(site, target) record where an unresolved call lands, when the code makes it certain; impact, path and tests then walk it, labelled [asserted] context(task) how something works, as a narrative: the call flow step by step; context(task, source=True) carries each step's code Without the tools, the same from the shell: `axiomcode impact `, -`axiomcode path `, `axiomcode tests`, `axiomcode context "" --source`. +`axiomcode path `, `axiomcode tests`, `axiomcode value `, `axiomcode context "" --source`. Every answer is a numbered list of places, each with the code of the function it sits in and the line that matters marked `→`: answer from that code, and open a file only where a body was cut. A `resolved` place has diff --git a/plugins/axiomcode/skills/axiomcode/SKILL.md b/plugins/axiomcode/skills/axiomcode/SKILL.md index a4f480f51..bb0d4fc6c 100644 --- a/plugins/axiomcode/skills/axiomcode/SKILL.md +++ b/plugins/axiomcode/skills/axiomcode/SKILL.md @@ -1,20 +1,21 @@ --- 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, 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#, and HTML + CSS (impact .class, #id, a custom property as var(…), page.html, sheet.css, page.html:LINE; each language its own graph). + 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, value(name) for the current value of a named constant, 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 value `, `axiomcode context "" --source`. Java, TypeScript, Python, JavaScript, C#, and HTML + CSS (impact .class, #id, a custom property as var(…), page.html, sheet.css, page.html:LINE; each language its own graph). --- # 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`, `__context`); otherwise run +`mcp__plugin_axiomcode_axiomcode__impact`, `__path`, `__tests`, `__value`, `__context`); otherwise run `/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 is the current value of constant X (a limit, a default, a threshold)? | `value(name)` | `axiomcode value ` | +| who reads constant X, and what does changing it reach? | `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` | @@ -50,6 +51,15 @@ With a name: who calls it, what depends on it further out, and the tests that ex 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. +## value + +The current value of a named constant: the value, its kind (number, string, bool) and the line that writes it, as +`file:line` with that line's source. Example: `value(name="MAX_ITEMS")`. Qualify it by its class or object for one +member (`Pager.MAX_ITEMS`); `MAX_*` lists every constant whose name matches. A name defined in several places is +listed in all of them, with how many; none is chosen for you. A name declared with no single literal value (a call, +a dict or object, an expression) prints its declaration line instead, a local says it is a local and where it is +assigned, and a name the graph does not know says so. Who reads the constant is `impact(name)`. + ## path How one declaration reaches another: every hop of the call chain, with the code at the line each call is written on. diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py index e1fc01047..c9cd875de 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py @@ -1467,7 +1467,7 @@ def query_names(verb, args, repo): i += 2; continue if not a.startswith('-') and not (os.path.isdir(a) and os.path.realpath(a) == repo): pos.append(a) i += 1 - if verb in ('impact', 'path'): names += [p for p in pos if p != '*'] + if verb in ('impact', 'path', 'value'): names += [p for p in pos if p != '*'] return names def names_in_edits(repo, names, stale): diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode index 8fea778ca..a2bd8dba9 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode @@ -12,6 +12,10 @@ # axiomcode context "" [--source] # how something works, as a narrative: the files and callables the task touches and, for a # how-does-X-work question, the call flow step by step; --source carries each step's code. +# axiomcode value +# the current value of a named constant: its value, its kind and the line that writes it, file:line with the +# source. Pager.MAX_ITEMS for a class's; MAX_* for every one matching. A name defined in several places is listed +# in all of them; a name that is declared without a literal value, or is a local, or is unknown, says which. # axiomcode link # record where an unresolved call lands, when the code makes it certain: impact, path and tests then walk it, # labelled [asserted]. Alone, lists every link and whether the graph took it; `link -` removes one. @@ -72,7 +76,7 @@ # language's, the one with the most files, first). --lang restricts it to one language or a comma list, the first the main one; # --src limits the analysed tree (e.g. src); --library names dependency roots. --fresh (context, path, impact) waits for # a refresh in flight and answers from the new graph; without it an answer from a graph older than an edit marks its rows. -# --no-refresh (every query verb: context, path, impact, changed, test-impact; MCP refresh=false) is a READ-ONLY +# --no-refresh (every query verb: context, path, impact, value, changed, test-impact; MCP refresh=false) is a READ-ONLY # query: the answer comes from the graph as it is and no rebuild is started, as AXIOMCODE_NO_REFRESH=1 does for a whole # shell; it still says which edits the graph predates. Without it a query on a graph that is out of date (files edited # since, or built by another axiomcode) starts a background rebuild with the engine this axiomcode uses, and says so on @@ -126,7 +130,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|report) + case "$1" in impact|path|tests|link|report|value) helptext | awk -v v="$1" '$0 ~ "^ axiomcode "v"( |$)" {on=1; print; next} on && /^ axiomcode / {exit} on && /^$/ {exit} on {print}' return 0 ;; esac @@ -197,6 +201,7 @@ case "$cmd" in 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 ;; + value) if [ "${#POS[@]}" -gt 1 ] && [ ! -d "${POS[1]}" ]; then gone "${POS[1]}"; fi ;; impact) if [ "${#POS[@]}" -gt 1 ] && [ ! -e "$LASTPOS" ] && dirlike "$LASTPOS"; then gone "$LASTPOS"; fi ;; changed|test-impact|tests) if [ "${#POS[@]}" -gt 0 ] && [ ! -e "${POS[0]}" ] && dirlike "${POS[0]}"; then gone "${POS[0]}"; fi ;; esac @@ -251,6 +256,8 @@ Q=""; [ -d "$FR/.axiomcode/lang" ] && [ -z "${AXIOMCODE_GRAPH:-}" ] && Q="$H/ax_ # (-L too: a pointer whose target is gone is a graph to repair — the refresher does it — not a repository without one) if [ -f "$FR/.axiomcode/out/graph.sqlite" ] || [ -L "$FR/.axiomcode/out/graph.sqlite" ]; then case "$cmd" in + # value asks every language's graph itself (one answer, its rows from all of them), so it is not fanned out + value) exec python3 "$H/ax_fresh.py" query "$FR" value -- python3 "$H/axiomcode-value" ${ARGS[@]+"${ARGS[@]}"} ;; context|path|impact) exec python3 "$H/ax_fresh.py" query "$FR" "$cmd" -- ${G[@]+"${G[@]}"} python3 ${Q:+"$Q" "$FR"} "$H/axiomcode-$cmd" ${ARGS[@]+"${ARGS[@]}"} ;; changed|test-impact|tests) python3 "$H/ax_fresh.py" baseline "$FR" "${AXIOMCODE_BASELINE_WAIT:-30}" || true ;; esac @@ -263,6 +270,7 @@ 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[@]}"} ;; + value) exec python3 "$H/axiomcode-value" ${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 diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-value b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-value new file mode 100755 index 000000000..e04ce0d45 --- /dev/null +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-value @@ -0,0 +1,245 @@ +#!/usr/bin/env python3 +"""axiomcode value [] [--json] + +The current value of a named constant, from the graph's literals: the value, its kind (number, string, bool) and the +line that writes it, `file:line` with that line's source. A name is written as in the code: `MAX_ITEMS`, or qualified by +its class or object, `Pager.MAX_ITEMS`. `MAX_*` (a `*` or `?` anywhere) lists every constant whose name matches. + + - an unqualified name answers for every declaration whose last part it is (the module's MAX_ITEMS and + Pager.MAX_ITEMS): the exact name first, then the others. A name defined in several places is listed in all of + them, with how many; none is chosen. + - a name the graph declares but holds no single literal for (a call, a dict or object, an expression) says so, and + prints the declaration's line, so the value can be read there. + - a local variable is not a constant: its first assignment is printed, and said to be a local. + - a name the graph does not know at all is said to be unknown (exit 1). + +Every language's graph of the repository is asked. --json prints one document: {name, status, count, values, +declarations}, each row with `at` (file:line), `code` (the line) and, for a value, `value` and `kind`. status is +`literal` (values found), `not_literal` (declared, no single literal), `local` or `unknown`. +""" +import fnmatch, json, os, re, sqlite3, sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) +import ax_contract, ax_fresh, ax_langs + +VALUE_KINDS = ('const', 'variable', 'field', 'enum_member') +LOCAL_EKINDS = ('LOCAL_VARIABLE', 'VARIABLE') + + +def die(msg, code=2): + print(msg); sys.exit(code) + + +def graph_dbs(repo): + """[(language, graph.sqlite)] to ask: the one AXIOMCODE_GRAPH names, else the main graph and every other language's""" + placed = os.environ.get('AXIOMCODE_GRAPH') + if placed: + return [(os.environ.get('AXIOMCODE_GRAPH_LANG') or '', os.path.join(placed, 'out', 'graph.sqlite'))] + gdir = ax_contract.usable_graph(repo, os.path.join(repo, '.axiomcode')) + db = os.path.join(gdir, 'out', 'graph.sqlite') + if not os.path.exists(db) and not ax_contract.ensure_graph(repo, db): die(ax_contract.no_graph(repo, db)) + out = [(ax_langs.main_language(repo), db)] + for lang, d in ax_langs.graphs(repo)[1:]: + p = os.path.join(d, 'out', 'graph.sqlite') + if not ax_fresh.graph_corrupt(p): out.append((lang, p)) + return out + + +def connect(repo, db): + con = sqlite3.connect(f"file:{db}?mode=ro", uri=True); con.row_factory = sqlite3.Row + tables = {r[0] for r in con.execute("SELECT name FROM sqlite_master WHERE type IN ('table', 'view')")} + if 'symbols' not in tables: + # a graph built but never indexed: the index is this tool's own and takes seconds (as every verb does it) + import subprocess + con.close(); subprocess.run([sys.executable, os.path.join(HERE, 'axiomcode-index'), repo], capture_output=True) + con = sqlite3.connect(f"file:{db}?mode=ro", uri=True); con.row_factory = sqlite3.Row + tables = {r[0] for r in con.execute("SELECT name FROM sqlite_master WHERE type IN ('table', 'view')")} + cols = {r[1] for r in con.execute("PRAGMA table_info(literals)")} if 'literals' in tables else set() + return con, tables, cols + + +def matcher(name): + """(is_glob, test(display) -> 0 exact / 1 by its last part(s) / None no match)""" + glob = any(ch in name for ch in '*?[') + def test(display): + if not display: return None + if glob: + if fnmatch.fnmatchcase(display, name): return 0 + # `MAX_*` matches Pager.MAX_ITEMS by its last part; `Pager.*` matches the members of Pager wherever it nests + parts = display.split('.') + for i in range(1, len(parts)): + if fnmatch.fnmatchcase('.'.join(parts[i:]), name): return 1 + return None + if display == name: return 0 + if display.endswith('.' + name): return 1 + return None + return glob, test + + +def candidates_sql(name, glob): + """a cheap SQL prefilter on the last part of the name, so a large graph is not read whole""" + last = name.rsplit('.', 1)[-1] + if glob: + return "GLOB ?", '*' + last if not last.startswith('*') else last + return "GLOB ?", '*' + last.replace('[', '[[]') + + +def source_line(repo, rel, line, cache={}): + if rel not in cache: + try: + with open(os.path.join(repo, rel), encoding='utf-8', errors='replace') as f: cache[rel] = f.read().split('\n') + except OSError: cache[rel] = None + lines = cache[rel] + if not lines or not line or line > len(lines): return '' + return lines[line - 1].strip() + + +def local_written(repo, con, name, rel, first_use): + """(line, 'assigned' | 'used') of a local: the refs table records where a local is USED, and in some languages + (TypeScript) not where it is declared, so the lines of the function that holds its first use are read up to that + use for `const|let|var name` or `name =`; the first use itself when none is found""" + r = con.execute("SELECT line FROM symbols WHERE file = ? AND method_id IS NOT NULL AND kind <> 'module' AND line <= ? AND end_line >= ? " + "ORDER BY line DESC LIMIT 1", (rel, first_use, first_use)).fetchone() + start = r[0] if r else max(1, first_use - 50) + n = re.escape(name) + pat = re.compile(r'\b(const|let|var|final|val)\s+(\[[^\]]*|\{[^}]*)?\b%s\b|(^|[^.\w])%s\s*(:[^=]*)?(?])=(?!=)' % (n, n)) + for ln in range(start, first_use + 1): + if pat.search(source_line(repo, rel, ln)): return ln, 'assigned' + return first_use, 'used' + + +def shown(v, kind): + return f"'{v}'" if kind == 'string' else v + + +def ask(repo, lang, db, name): + """the rows one graph holds for `name`: (values, declarations without a value, locals, other declarations)""" + con, tables, cols = connect(repo, db) + glob, test = matcher(name) + op, pat = candidates_sql(name, glob) + values, decls, locals_, others = [], [], [], [] + try: + if 'literals' in tables and 'name' in cols: + for r in con.execute(f"SELECT value, file, line, kind, name FROM literals WHERE name IS NOT NULL AND name {op}", (pat,)): + rank = test(r['name']) + if rank is None: continue + values.append(dict(rank=rank, name=r['name'], value=r['value'], kind=r['kind'], file=r['file'], line=r['line'], + language=lang)) + valued = {(v['name'], v['file'], v['line']) for v in values} + for r in con.execute(f"SELECT display, kind, file, line, end_line, is_test FROM symbols WHERE display {op} OR name {op}", (pat, pat)): + rank = test(r['display']) + if rank is None or (r['display'], r['file'], r['line']) in valued or '<' in r['display']: continue + row = dict(rank=rank, name=r['display'], kind=r['kind'], file=r['file'], line=r['line'], language=lang) + (decls if r['kind'] in VALUE_KINDS else others).append(row) + if not values and not decls and not others and 'refs' in tables and not glob and '.' not in name: + # a local: the graph records where it is written, not a value. Its first mention is where it is assigned + for r in con.execute("SELECT file, MIN(line) AS line FROM refs WHERE name = ? AND entity_kind IN (%s) GROUP BY file ORDER BY file" + % ','.join('?' * len(LOCAL_EKINDS)), (name, *LOCAL_EKINDS)): + line, how = local_written(repo, con, name, r['file'], r['line']) + locals_.append(dict(rank=0, name=name, kind='local', file=r['file'], line=line, how=how, language=lang)) + if not values and not decls and not others and '.' in name and not glob: + # `CONFIG.timeout` where CONFIG is declared and its member is not: the owner's declaration holds the value + owner = name.rsplit('.', 1)[0] + _, otest = matcher(owner) + for r in con.execute("SELECT display, kind, file, line FROM symbols WHERE display = ? OR display GLOB ?", (owner, '*.' + owner.replace('[', '[[]'))): + if otest(r['display']) is not None and r['kind'] in VALUE_KINDS: + decls.append(dict(rank=0, name=r['display'], kind=r['kind'], file=r['file'], line=r['line'], language=lang, owner_of=name)) + finally: + con.close() + named = 'literals' in tables and 'name' in cols + return values, decls, locals_, others, named + + +def order(rows): + return sorted(rows, key=lambda r: (r['rank'], r['file'] or '', r['line'] or 0, r['name'])) + + +def main(argv): + args = list(argv); as_json = '--json' in args + args = [a for a in args if a != '--json'] + flags = [a for a in args if a.startswith('-') and a != '-'] + if flags: die(f"axiomcode value: unknown option {flags[0]}: usage: axiomcode value []") + repo = '.' + if len(args) > 1 and os.path.isdir(args[-1]): repo = args.pop() + if len(args) != 1 or not args[0].strip(): + die("usage: axiomcode value [] (the value of a named constant: MAX_ITEMS, Pager.MAX_ITEMS, MAX_*)") + name = args[0].strip() + repo = os.path.realpath(repo) + os.environ.setdefault('AXIOMCODE_REPO', repo) + + values, decls, locals_, others, old = [], [], [], [], [] + for lang, db in graph_dbs(repo): + v, d, l, o, named = ask(repo, lang, db, name) + values += v; decls += d; locals_ += l; others += o + if not named: old.append(lang or 'main') + values, decls, locals_, others = order(values), order(decls), order(locals_), order(others) + for r in values + decls + locals_ + others: + r['at'] = f"{r['file']}:{r['line']}"; r['code'] = source_line(repo, r['file'], r['line']) + status = 'literal' if values else 'not_literal' if decls else 'local' if locals_ else 'unknown' + stale_index = (f"the {', '.join(old)} graph's index predates named literals, so it cannot answer this; " + f"`axiomcode index` rebuilds it") if old else '' + + if as_json: + strip = lambda rows: [{k: v for k, v in r.items() if k != 'rank'} for r in rows] + doc = dict(name=name, status=status, count=len(values), values=strip(values), + declarations=strip(decls), locals=strip(locals_), other_declarations=strip(others)) + if stale_index: doc['note'] = stale_index + print(json.dumps(doc, indent=1, ensure_ascii=False)) + return 0 if status != 'unknown' else 1 + + def place(r, indent=' '): + line = f"{indent}{r['at']} {r['code']}" if r['code'] else f"{indent}{r['at']}" + # the line is read from the file now, the value from the graph: an edit since the build shows as a mismatch + if 'value' in r and r['code'] and r['value'] not in r['code']: + line += f"\n{indent}(this line no longer writes {shown(r['value'], r['kind'])}: the graph predates an edit to it)" + return line + + out = [] + if values: + if len(values) == 1: + v = values[0] + out.append(f"{v['name']} = {shown(v['value'], v['kind'])} [{v['kind']}]") + out.append(place(v)) + else: + if any(c in name for c in '*?['): out.append(f"{name}: {len(values)} constants match:") + else: out.append(f"{name} is defined in {len(values)} places, every one listed, none chosen:") + for i, v in enumerate(values, 1): + out.append(f"{i}. {v['name']} = {shown(v['value'], v['kind'])} [{v['kind']}]") + out.append(place(v)) + if decls: + out.append(f"\nalso declared, with no single literal value ({len(decls)}):") + for d in decls: out.append(f"- {d['name']} [{d['kind']}]"); out.append(place(d)) + elif decls: + for i, d in enumerate(decls): + if i: out.append('') + if d.get('owner_of'): + out.append(f"{name}: not a declaration the graph holds; {d['name']} is a {d['kind']} declared at {d['at']}, " + f"and the value is in its initializer:") + else: + out.append(f"{d['name']} is a {d['kind']}, but not a literal-valued constant: its value is not one literal " + f"(a call, an expression, a collection), so the graph records no value for it. Its declaration:") + out.append(place(d)) + if len(decls) > 1: out.insert(0, f"{name}: {len(decls)} declarations, none with a single literal value:\n") + elif locals_: + out.append(f"{name} is a local variable, not a constant: the graph records no value for a local. " + f"Where it is {'assigned' if all(l['how'] == 'assigned' for l in locals_) else 'first written or used'}" + f"{' in each file' if len(locals_) > 1 else ''}:") + for l in locals_: out.append(place(l)) + elif others: + out.append(f"{name} is declared, but not as a constant: " + ', '.join(sorted({o['kind'] for o in others})) + ".") + for o in others: out.append(f"- {o['name']} [{o['kind']}]"); out.append(place(o)) + else: + out.append(f"nothing named {name} in this graph: no constant, field, variable or other declaration by that name.") + if stale_index: out.append(stale_index) + print('\n'.join(out)); return 1 + if values and others: + out.append(f"\n{name} also names {len(others)} declaration(s) that are not constants: " + + ', '.join(f"{o['name']} [{o['kind']}] {o['at']}" for o in others[:5]) + (' …' if len(others) > 5 else '')) + if stale_index: out.append(stale_index) + print('\n'.join(out)) + return 0 + + +if __name__ == '__main__': + sys.exit(main(sys.argv[1:])) diff --git a/skills/axiomcode/SKILL.md b/skills/axiomcode/SKILL.md index ae84f0056..83645704d 100644 --- a/skills/axiomcode/SKILL.md +++ b/skills/axiomcode/SKILL.md @@ -1,20 +1,21 @@ --- 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, context(task, source=True) for how something works as a step-by-step call flow with each step's code. Only when those tools are not in your list, the same from the shell: `axiomcode impact `, `axiomcode path `, `axiomcode tests`, `axiomcode context "" --source`. Java, TypeScript, Python, JavaScript, C#. + Use for any why, what or where question about code — how a codebase works, where something lives, who calls it, what a change to it breaks, which tests cover an edit. Also use when resolving an issue or bug report, which names a symptom rather than a file. Examples: "How does X work?", "Where do I change Y?", "What calls this?", "What breaks if I change Z?", "Which tests do I run?", "Fix this issue". Search with grep as usual; ask the graph for what grep cannot know — which declaration a call reaches and the callers that never spell the name. Answers come from a resolved call graph, so they include callers that never spell the name — through an interface, an override, a callback, dependency injection or a config key — and every place comes with the code of the function it sits in. Call the MCP tools directly, no need to load this skill first: impact(name) for who calls it and what a change reaches (with no name: your uncommitted edits), path(start, end) for how A reaches B, tests() for the tests your edits reach, value(name) for the current value of a named constant, 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 value `, `axiomcode context "" --source`. Java, TypeScript, Python, JavaScript, C#, and HTML + CSS (impact .class, #id, a custom property as var(…), page.html, sheet.css, page.html:LINE; each language its own graph). --- # 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`, `__context`); otherwise run +`mcp__plugin_axiomcode_axiomcode__impact`, `__path`, `__tests`, `__value`, `__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 is the current value of constant X (a limit, a default, a threshold)? | `value(name)` | `axiomcode value ` | +| who reads constant X, and what does changing it reach? | `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` | @@ -50,6 +51,15 @@ With a name: who calls it, what depends on it further out, and the tests that ex 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. +## value + +The current value of a named constant: the value, its kind (number, string, bool) and the line that writes it, as +`file:line` with that line's source. Example: `value(name="MAX_ITEMS")`. Qualify it by its class or object for one +member (`Pager.MAX_ITEMS`); `MAX_*` lists every constant whose name matches. A name defined in several places is +listed in all of them, with how many; none is chosen for you. A name declared with no single literal value (a call, +a dict or object, an expression) prints its declaration line instead, a local says it is a local and where it is +assigned, and a name the graph does not know says so. Who reads the constant is `impact(name)`. + ## path How one declaration reaches another: every hop of the call chain, with the code at the line each call is written on. diff --git a/tests/cases/python/value-verb/case.json b/tests/cases/python/value-verb/case.json new file mode 100644 index 000000000..e6d53bee8 --- /dev/null +++ b/tests/cases/python/value-verb/case.json @@ -0,0 +1,38 @@ +{"lang": "python", "src": "src", + "checks": [ + {"why": "a module constant answers with its value, its kind and the line that writes it", + "run": ["value", "API_URL"], + "want": ["API_URL = 'https://example.invalid/api' [string]", "limits.py:2 "]}, + {"why": "a name defined in several places lists every one with the count, the exact name first, and chooses none", + "run": ["value", "MAX_ITEMS"], + "want": ["MAX_ITEMS is defined in 3 places, every one listed, none chosen:", "1. MAX_ITEMS = 5 [number]", "limits.py:1", "MAX_ITEMS = 9", "other.py:1", "3. Pager.MAX_ITEMS = 100 [number]"]}, + {"why": "a qualified Class.NAME answers for that member alone", + "run": ["value", "Pager.MAX_ITEMS"], + "want": ["Pager.MAX_ITEMS = 100 [number]"], + "avoid": ["MAX_ITEMS = 5", "MAX_ITEMS = 9", "defined in"]}, + {"why": "a glob lists every constant whose name matches it, and nothing else", + "run": ["value", "MAX_*"], + "want": ["MAX_*: 4 constants match:", "MAX_RETRIES = 4"], + "avoid": ["PAGE_SIZE", "API_URL"]}, + {"why": "control: a constant initialized through a CALL has no literal value; the 3 on its line is an argument, and the declaration line is printed instead", + "run": ["value", "RETRY_LIMIT"], + "want": ["RETRY_LIMIT is a const, but not a literal-valued constant", "pick(3)"], + "avoid": ["RETRY_LIMIT = 3", "[number]"]}, + {"why": "a dict / object constant is declared with no single literal: said, with its line", + "run": ["value", "CONFIG"], + "want": ["not a literal-valued constant", "limits.py:3"]}, + {"why": "a local is said to be a local, with the line that assigns it", + "run": ["value", "batch"], + "want": ["batch is a local variable, not a constant", "batch = 10"]}, + {"why": "a function is declared, but not as a constant", + "run": ["value", "pick"], + "want": ["pick is declared, but not as a constant: function."]}, + {"why": "a name the graph does not know at all is unknown, exit 1", + "run": ["value", "NO_SUCH_LIMIT"], + "want": ["nothing named NO_SUCH_LIMIT in this graph"], + "expect_error": true}, + {"why": "--json is one document with the status, the count and each value's location", + "run": ["value", "MAX_ITEMS", "--json"], + "stdout_json": true, + "want": ["\"status\": \"literal\"", "\"count\": 3", "\"at\": \"src/other.py:1\""]} + ]} diff --git a/tests/cases/python/value-verb/src/limits.py b/tests/cases/python/value-verb/src/limits.py new file mode 100644 index 000000000..7389daeb2 --- /dev/null +++ b/tests/cases/python/value-verb/src/limits.py @@ -0,0 +1,19 @@ +MAX_ITEMS = 5 +API_URL = "https://example.invalid/api" +CONFIG = {"timeout": 30, "retries": 3} + + +def pick(n): + return min(n, MAX_ITEMS) + + +RETRY_LIMIT = pick(3) + + +class Pager: + PAGE_SIZE = 50 + MAX_ITEMS = 100 + + def fetch(self): + batch = 10 + return batch * self.PAGE_SIZE diff --git a/tests/cases/python/value-verb/src/other.py b/tests/cases/python/value-verb/src/other.py new file mode 100644 index 000000000..26bd21810 --- /dev/null +++ b/tests/cases/python/value-verb/src/other.py @@ -0,0 +1,2 @@ +MAX_ITEMS = 9 +MAX_RETRIES = 4 diff --git a/tests/cases/typescript/value-verb/case.json b/tests/cases/typescript/value-verb/case.json new file mode 100644 index 000000000..ebb370104 --- /dev/null +++ b/tests/cases/typescript/value-verb/case.json @@ -0,0 +1,38 @@ +{"lang": "typescript", "src": "src", + "checks": [ + {"why": "a module constant answers with its value, its kind and the line that writes it", + "run": ["value", "API_URL"], + "want": ["API_URL = 'https://example.invalid/api' [string]", "limits.ts:2 "]}, + {"why": "a name defined in several places lists every one with the count, the exact name first, and chooses none", + "run": ["value", "MAX_ITEMS"], + "want": ["MAX_ITEMS is defined in 3 places, every one listed, none chosen:", "1. MAX_ITEMS = 5 [number]", "limits.ts:1", "MAX_ITEMS = 9", "other.ts:1", "3. Pager.MAX_ITEMS = 100 [number]"]}, + {"why": "a qualified Class.NAME answers for that member alone", + "run": ["value", "Pager.MAX_ITEMS"], + "want": ["Pager.MAX_ITEMS = 100 [number]"], + "avoid": ["MAX_ITEMS = 5", "MAX_ITEMS = 9", "defined in"]}, + {"why": "a glob lists every constant whose name matches it, and nothing else", + "run": ["value", "MAX_*"], + "want": ["MAX_*: 4 constants match:", "MAX_RETRIES = 4"], + "avoid": ["PAGE_SIZE", "API_URL"]}, + {"why": "control: a constant initialized through a CALL has no literal value; the 3 on its line is an argument, and the declaration line is printed instead", + "run": ["value", "RETRY_LIMIT"], + "want": ["RETRY_LIMIT is a const, but not a literal-valued constant", "pick(3)"], + "avoid": ["RETRY_LIMIT = 3", "[number]"]}, + {"why": "a dict / object constant is declared with no single literal: said, with its line", + "run": ["value", "CONFIG"], + "want": ["not a literal-valued constant", "limits.ts:3"]}, + {"why": "a local is said to be a local, with the line that assigns it", + "run": ["value", "batch"], + "want": ["batch is a local variable, not a constant", "batch = 10"]}, + {"why": "a function is declared, but not as a constant", + "run": ["value", "pick"], + "want": ["pick is declared, but not as a constant: function."]}, + {"why": "a name the graph does not know at all is unknown, exit 1", + "run": ["value", "NO_SUCH_LIMIT"], + "want": ["nothing named NO_SUCH_LIMIT in this graph"], + "expect_error": true}, + {"why": "--json is one document with the status, the count and each value's location", + "run": ["value", "MAX_ITEMS", "--json"], + "stdout_json": true, + "want": ["\"status\": \"literal\"", "\"count\": 3", "\"at\": \"src/other.ts:1\""]} + ]} diff --git a/tests/cases/typescript/value-verb/src/limits.ts b/tests/cases/typescript/value-verb/src/limits.ts new file mode 100644 index 000000000..255e3afaf --- /dev/null +++ b/tests/cases/typescript/value-verb/src/limits.ts @@ -0,0 +1,19 @@ +export const MAX_ITEMS = 5; +export const API_URL = "https://example.invalid/api"; +export const CONFIG = { timeout: 30, retries: 3 }; + +export function pick(n: number): number { + return Math.min(n, MAX_ITEMS); +} + +export const RETRY_LIMIT = pick(3); + +export class Pager { + static PAGE_SIZE = 50; + static MAX_ITEMS = 100; + + fetch(): number { + const batch = 10; + return batch * Pager.PAGE_SIZE; + } +} diff --git a/tests/cases/typescript/value-verb/src/other.ts b/tests/cases/typescript/value-verb/src/other.ts new file mode 100644 index 000000000..0c05e5799 --- /dev/null +++ b/tests/cases/typescript/value-verb/src/other.ts @@ -0,0 +1,2 @@ +export const MAX_ITEMS = 9; +export const MAX_RETRIES = 4; diff --git a/tests/mcp.py b/tests/mcp.py index f85b37d37..84bf97a7d 100644 --- a/tests/mcp.py +++ b/tests/mcp.py @@ -32,7 +32,7 @@ # THE SMALL SURFACE: four questions, each with at most two parameters and no options. The front-door answer is capped # at ten places with the rest counted, so no tool is paged. context is the one narrative verb: a task in words, # answered as the verb's own flow rather than as places. -TOOLS = {'context': ['task', 'source'], 'impact': ['name'], 'path': ['start', 'end'], 'tests': [], 'link': ['site', 'target']} +TOOLS = {'context': ['task', 'source'], 'impact': ['name'], 'path': ['start', 'end'], 'tests': [], 'link': ['site', 'target'], 'value': ['name']} def exchange(cmd, cwd, env=None, workdir=None): @@ -119,7 +119,9 @@ def check_arguments(label, cmd, cwd, lax=False): ('impact', {'name': 'A.f', 'repo': cwd}, 'repo: unexpected argument'), ('tests', {'why': True}, 'why: unexpected argument'), ('context', {}, 'task'), - ('context', {'task': 'x', 'budget': 3}, 'budget: unexpected argument')] + ('context', {'task': 'x', 'budget': 3}, 'budget: unexpected argument'), + ('value', {}, 'name'), + ('value', {'name': 'MAX_ITEMS', 'json': True}, 'json: unexpected argument')] for name, args, field in wrong: res = call(cmd, cwd, name, args) text = ' '.join(c.get('text', '') for c in res.get('content', [])) @@ -129,7 +131,7 @@ def check_arguments(label, cmd, cwd, lax=False): # the control: every parameter a tool declares still passes, including the ones the CLI's hints name right = [('impact', {'name': 'A.f'}), ('impact', {}), ('path', {'start': 'a', 'end': 'b'}), ('tests', {}), - ('context', {'task': 'x'}), ('context', {'task': 'x', 'source': True})] + ('context', {'task': 'x'}), ('context', {'task': 'x', 'source': True}), ('value', {'name': 'MAX_ITEMS'})] for name, args in right: res = call(cmd, cwd, name, args) text = ' '.join(c.get('text', '') for c in res.get('content', [])) @@ -153,7 +155,8 @@ def check_front_door(): (lambda: server.impact(''), ['impact', cwd]), (lambda: server.impact(), ['impact', cwd]), (lambda: server.path('a', 'b'), ['path', 'a', 'b', cwd]), - (lambda: server.tests(), ['tests', cwd])): + (lambda: server.tests(), ['tests', cwd]), + (lambda: server.value('MAX_ITEMS'), ['value', 'MAX_ITEMS', cwd])): seen.clear(); call() if seen != [want]: bad.append(f"front door: ran {seen}, want {[want]}") if any(a.startswith('-') for a in (seen[0] if seen else [])): bad.append(f"front door: a flag was passed: {seen}") diff --git a/tests/mcp_docs.py b/tests/mcp_docs.py index 900924811..0c05d2f75 100644 --- a/tests/mcp_docs.py +++ b/tests/mcp_docs.py @@ -26,7 +26,7 @@ [os.path.join(ROOT, 'plugins', 'axiomcode', 'AGENTS.md')] + \ sorted(glob.glob(os.path.join(ROOT, 'plugins', 'axiomcode', 'rules', '*.mdc'))) -VERB = r'(find|impact|path|tests)' +VERB = r'(find|impact|path|tests|context|link|value)' # a tool named: a call `impact(`, the Claude Code name mcp__plugin_axiomcode_axiomcode__impact, MCP `impact`, or a command # in backticks (`axiomcode impact x`); `path:line: …` is an answer's shape, not the tool MENTION = re.compile(r'`(?:axiomcode )?' + VERB + r'(?:[ \t][^`\n]*)?`|\bmcp__plugin_axiomcode_axiomcode__' + VERB + r'\b' @@ -130,7 +130,7 @@ def controls(tools): got = len(mismatches(documented(text), fake, 'control')) if got != want: bad.append(f"control {text!r}: {got} mismatch(es), want {want}") - if set(tools) != {'impact', 'path', 'tests'}: + if set(tools) != {'context', 'impact', 'link', 'path', 'tests', 'value'}: bad.append(f"the server listed {sorted(tools)}; the MCP tools were not read") return bad diff --git a/tests/surfaces.py b/tests/surfaces.py index 09899d2b3..2521d4af1 100644 --- a/tests/surfaces.py +++ b/tests/surfaces.py @@ -29,7 +29,7 @@ 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', 'report', 'graph'] +PUBLIC = ['index', 'impact', 'path', 'tests', 'value', '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'}