From 514fbe4b34092c939bf2a2fe09c991e6d6fa8665 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:32:23 -0700 Subject: [PATCH] plugin: answer at once from the last graph with stale rows marked; watch what each parser reads (#1594, #1595) A query no longer spends a fixed 10 s wait that no measured rebuild fits in. It answers from the last good graph and marks every row that lies in a file edited, added or removed since, "(may be out of date)" in text and "stale": true in --json (MCP gets the text). It waits, up to AXIOMCODE_FRESH_WAIT (30 s), only when the answer or the name asked about touches an edited file and the last build of the repository says the refresh will finish within that; never on a compile of the engine's rules. --fresh (MCP fresh=true) waits for the refresh with progress lines and answers from the new graph; SKILL.md says to use it before a delete or a rename. axiomcode-build records how long each build took (build-seconds). The file table now prunes, per language, exactly the directories that language's parser skips, so an edit under out/, build/, target/ or coverage/ where the parser reads it makes the graph stale. --- plugins/axiomcode/mcp/server.py | 22 +- plugins/axiomcode/skills/axiomcode/SKILL.md | 8 +- .../skills/axiomcode/scripts/ax_fresh.py | 276 ++++++++++++++++-- .../skills/axiomcode/scripts/axiomcode | 36 ++- .../skills/axiomcode/scripts/axiomcode-build | 4 + skills/axiomcode/SKILL.md | 8 +- tests/README.md | 5 + tests/freshness.py | 245 ++++++++++++++++ tests/latency.py | 4 +- tests/refresh.py | 18 +- 10 files changed, 574 insertions(+), 52 deletions(-) create mode 100644 tests/freshness.py diff --git a/plugins/axiomcode/mcp/server.py b/plugins/axiomcode/mcp/server.py index 9c1809567..50d016ff6 100755 --- a/plugins/axiomcode/mcp/server.py +++ b/plugins/axiomcode/mcp/server.py @@ -27,8 +27,8 @@ PARAM = {'--in': 'in_path', '--tests-only': 'tests', '--tests': 'tests', '--tests-in': 'tests_in', '--from': 'from_', '--why': 'why', '--source': 'source', '--explain': 'explain', '--every': 'every', '--staged': 'staged', '--impact': 'impact', '--delete': 'delete', '--depth': 'depth', '--limit': 'limit', '--page': 'page', - '--budget': 'budget', '--kind': 'kind', '--range': 'range'} -SWITCH = {'--tests-only', '--tests', '--why', '--source', '--explain', '--every', '--staged', '--impact', '--delete'} + '--budget': 'budget', '--kind': 'kind', '--range': 'range', '--fresh': 'fresh'} +SWITCH = {'--tests-only', '--tests', '--why', '--source', '--explain', '--every', '--staged', '--impact', '--delete', '--fresh'} PARAMS = {} # tool name -> its parameter names, filled as the tools are declared # a flag, and its value when what follows looks like one (, 'x', N, 2, a.b, src/x) rather than prose ("no --in was given") _FLAG = re.compile(r"(? str: - """[resolved]/[sound] rows are verified against the graph; the answer ends with `next:`, the one step to take. START HERE when you have a task in words and no name to ask about yet. A task that asks HOW something works ("how does X …", "explain …", or explain=True) also gets the call FLOW — every step in the order the calls are written, with ⚠ where the graph lost a call; from_ (comma-separated names) starts the flow where you choose. Pass source=True with it: each step then carries its code, so answer from that and open a file only for a step whose body was cut or a ⚠ call. Otherwise it returns the files and callables that task touches, from the problem statement alone. Deterministic — task terms scored against the graph's vocabulary by inverse document frequency, tests demoted, the closure walked from the best seed per term and ranked by nearest hop. in_path accepts SEVERAL paths, comma-separated: they are combined rather than intersected, so a change spanning two roots comes back in one call. budget is how many files are listed (default 12; the ranking is the same at any budget); source=True includes the code. A long answer comes in pages; ask for page=2 only if page 1's files are not enough. Ends by saying what it could not see.""" - a = ['context', task, repo] + (['--in', in_path] if in_path else []) + (['--budget', str(budget)] if budget else []) + (['--source'] if source else []) + (['--page', str(page)] if page and page != 1 else []) + (['--explain'] if explain else []) + [x for n in from_.split(',') if n.strip() for x in ('--from', n.strip())] +def axiomcode_context(task: str, repo: str = ".", in_path: str = '', budget: int = 0, source: bool = False, page: int = 1, explain: bool = False, from_: str = '', fresh: bool = False) -> str: + """[resolved]/[sound] rows are verified against the graph; the answer ends with `next:`, the one step to take. START HERE when you have a task in words and no name to ask about yet. A task that asks HOW something works ("how does X …", "explain …", or explain=True) also gets the call FLOW — every step in the order the calls are written, with ⚠ where the graph lost a call; from_ (comma-separated names) starts the flow where you choose. Pass source=True with it: each step then carries its code, so answer from that and open a file only for a step whose body was cut or a ⚠ call. Otherwise it returns the files and callables that task touches, from the problem statement alone. Deterministic — task terms scored against the graph's vocabulary by inverse document frequency, tests demoted, the closure walked from the best seed per term and ranked by nearest hop. in_path accepts SEVERAL paths, comma-separated: they are combined rather than intersected, so a change spanning two roots comes back in one call. budget is how many files are listed (default 12; the ranking is the same at any budget); source=True includes the code. A long answer comes in pages; ask for page=2 only if page 1's files are not enough. Ends by saying what it could not see. After an edit the answer comes at once from the last graph, rows in edited files marked (may be out of date); fresh=True waits for the rebuild.""" + a = ['context', task, repo] + (['--fresh'] if fresh else []) + (['--in', in_path] if in_path else []) + (['--budget', str(budget)] if budget else []) + (['--source'] if source else []) + (['--page', str(page)] if page and page != 1 else []) + (['--explain'] if explain else []) + [x for n in from_.split(',') if n.strip() for x in ('--from', n.strip())] return run(a) @srv.tool() -def axiomcode_path(from_: str, to: str, repo: str = ".", every: bool = False, in_path: str = '', depth: int = 0, limit: int = 0, page: int = 1) -> str: - """[resolved]/[sound] rows are verified against the graph, so a change need not re-derive them by reading (to explain how something works, read each hop's body); the answer ends with `next:`, the one step to take. A chain of calls from A to B in the graph, each hop verified, or why there is none. When you have ONE concept word you can name, a bare fragment resolves to every declaration containing it, so path('decrypt', '*') answers "what is the decryption code and what does it touch". For a whole task in words, with no name at all, use axiomcode_context first. Endpoints otherwise as written in the code: Owner.method, method, Type, Outer$Inner.m, file.java:123, file.py, @Decoration, a library call as written (new File, Files.readAllBytes). '*' on one side = everything that reaches B / everything A reaches. every=True lists every route; in_path restricts to files containing it; depth bounds a closure. A long answer comes in pages, nearest routes first, with the whole answer's counts on every page; ask for page=2 only if page 1 is not enough.""" - a = ['path', from_, to, repo] + (['--every'] if every else []) + (['--in', in_path] if in_path else []) + (['--depth', str(depth)] if depth else []) + (['--limit', str(limit)] if limit else []) + (['--page', str(page)] if page and page != 1 else []) +def axiomcode_path(from_: str, to: str, repo: str = ".", every: bool = False, in_path: str = '', depth: int = 0, limit: int = 0, page: int = 1, fresh: bool = False) -> str: + """[resolved]/[sound] rows are verified against the graph, so a change need not re-derive them by reading (to explain how something works, read each hop's body); the answer ends with `next:`, the one step to take. A chain of calls from A to B in the graph, each hop verified, or why there is none. When you have ONE concept word you can name, a bare fragment resolves to every declaration containing it, so path('decrypt', '*') answers "what is the decryption code and what does it touch". For a whole task in words, with no name at all, use axiomcode_context first. Endpoints otherwise as written in the code: Owner.method, method, Type, Outer$Inner.m, file.java:123, file.py, @Decoration, a library call as written (new File, Files.readAllBytes). '*' on one side = everything that reaches B / everything A reaches. every=True lists every route; in_path restricts to files containing it; depth bounds a closure. A long answer comes in pages, nearest routes first, with the whole answer's counts on every page; ask for page=2 only if page 1 is not enough. fresh=True: after an edit, wait for the rebuild instead of answering from the last graph with rows in edited files marked (may be out of date).""" + a = ['path', from_, to, repo] + (['--fresh'] if fresh else []) + (['--every'] if every else []) + (['--in', in_path] if in_path else []) + (['--depth', str(depth)] if depth else []) + (['--limit', str(limit)] if limit else []) + (['--page', str(page)] if page and page != 1 else []) return run(a) @srv.tool() -def axiomcode_impact(targets: list[str], repo: str = ".", tests: bool = False, why: bool = False, tests_in: str = '', depth: int = 0, in_path: str = '', kind: str = '', page: int = 1, budget: int = 0, limit: int = 0, delete: bool = False) -> str: - """Trust it: [resolved]/[sound] rows are verified against the graph, so do not re-derive them by reading; the answer ends with `next:`, the one step to take. What has to be looked at again when a declaration changes: must-change-with-it (overrides, subtypes), everything that directly uses it (with how sure each is), everything that reaches those, and the bound (unresolved calls). The tests are always counted, by rung, with the strong-route ones named and the top test files. Ask for the full list SECOND, only if you need it: tests=True returns ONLY the tests, grouped by rung and test file; why=True adds each test's route; tests_in narrows that listing to test files containing it. Long answers come in pages of ~2000 tokens: every page carries the counts of the WHOLE answer and the rows come strongest first, so page 1 is usually enough; ask for page=2 only if you need the weaker rows. budget changes the page size. Targets as written: Owner.method, Owner.field, Type, Owner.method(param), Type, Owner.method:local, or file.ts:123 (the declaration at that line). When you know where the declaration is, target it by file:line: a bare name answers for EVERY declaration of that name, and two unrelated functions in different files come back as one answer. kind: method|field|type|param|typeparam|var when a name is declared as several kinds. limit: rows shown per section (the `… +N (limit=N)` lines); delete=True adds a verdict on whether it is safe to delete.""" - a = ['impact', *targets, repo] + (['--tests-only'] if tests else []) + (['--why'] if why else []) + (['--tests-in', tests_in] if tests_in else []) + (['--depth', str(depth)] if depth else []) + (['--in', in_path] if in_path else []) + (['--kind', kind] if kind else []) + (['--page', str(page)] if page and page != 1 else []) + (['--budget', str(budget)] if budget else []) + (['--limit', str(limit)] if limit else []) + (['--delete'] if delete else []) +def axiomcode_impact(targets: list[str], repo: str = ".", tests: bool = False, why: bool = False, tests_in: str = '', depth: int = 0, in_path: str = '', kind: str = '', page: int = 1, budget: int = 0, limit: int = 0, delete: bool = False, fresh: bool = False) -> str: + """Trust it: [resolved]/[sound] rows are verified against the graph, so do not re-derive them by reading; the answer ends with `next:`, the one step to take. What has to be looked at again when a declaration changes: must-change-with-it (overrides, subtypes), everything that directly uses it (with how sure each is), everything that reaches those, and the bound (unresolved calls). The tests are always counted, by rung, with the strong-route ones named and the top test files. Ask for the full list SECOND, only if you need it: tests=True returns ONLY the tests, grouped by rung and test file; why=True adds each test's route; tests_in narrows that listing to test files containing it. Long answers come in pages of ~2000 tokens: every page carries the counts of the WHOLE answer and the rows come strongest first, so page 1 is usually enough; ask for page=2 only if you need the weaker rows. budget changes the page size. Targets as written: Owner.method, Owner.field, Type, Owner.method(param), Type, Owner.method:local, or file.ts:123 (the declaration at that line). When you know where the declaration is, target it by file:line: a bare name answers for EVERY declaration of that name, and two unrelated functions in different files come back as one answer. kind: method|field|type|param|typeparam|var when a name is declared as several kinds. limit: rows shown per section (the `… +N (limit=N)` lines); delete=True adds a verdict on whether it is safe to delete. fresh=True: after an edit, wait for the rebuild (use it before a delete or a rename) instead of answering from the last graph with rows in edited files marked (may be out of date).""" + a = ['impact', *targets, repo] + (['--fresh'] if fresh else []) + (['--tests-only'] if tests else []) + (['--why'] if why else []) + (['--tests-in', tests_in] if tests_in else []) + (['--depth', str(depth)] if depth else []) + (['--in', in_path] if in_path else []) + (['--kind', kind] if kind else []) + (['--page', str(page)] if page and page != 1 else []) + (['--budget', str(budget)] if budget else []) + (['--limit', str(limit)] if limit else []) + (['--delete'] if delete else []) return run(a) @srv.tool() diff --git a/plugins/axiomcode/skills/axiomcode/SKILL.md b/plugins/axiomcode/skills/axiomcode/SKILL.md index bcb292c1f..eaba7b67f 100644 --- a/plugins/axiomcode/skills/axiomcode/SKILL.md +++ b/plugins/axiomcode/skills/axiomcode/SKILL.md @@ -32,8 +32,12 @@ Read / Grep results as `graph: …` lines. Rules that decide whether an answer means anything: - **Never re-run `index` on an existing graph** "to make sure" or after your own edit. The graph refreshes itself in - the background after edits, with the flags it was built with; a query waits a few seconds for it. An answer that - still predates an edit says so on a `graph refresh:` line naming the files: read those for their current text. + the background after edits, with the flags it was built with. A query does not wait for it: it answers from the last + graph, names the edited files on a `graph refresh:` line, and marks every row that lies in one `(may be out of date)` + (`"stale": true` in `--json`); unmarked rows are current. Read a marked row's file for its current text. It waits + briefly on its own only when the answer touches an edited file and the rebuild is nearly done. +- **Before a delete or a rename, ask with `--fresh`** (MCP `fresh=true`): it waits for the rebuild, printing its + progress, and answers from a graph that includes every edit. A manual `index` with different flags (e.g. dropping `--library`) rebuilds a worse graph over the good one. - A repo in several languages is indexed in all of them, one graph each, and every query asks each graph; calls are not followed from one language to another. `--lang` restricts it, `--src src` narrows it; `--library ` so calls into dependencies diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py index dc29b8820..8b375db2f 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py @@ -13,20 +13,25 @@ waits out a burst of edits, rebuilds with the language / --src / --library of the graph it replaces, and checks again: an edit made DURING the build is caught by the next pass. the triggers hooks (after an edit, a shell command, a turn, at session start, on a prompt) only START - the worker and return. Query verbs start it and wait a bounded time (`wait`), then answer - from the previous graph and name the files that changed since. + the worker and return. Query verbs start it and answer from the previous graph at once, with + every row that lies in a file changed since marked (`query`); they wait only when the answer + touches such a file and the refresh is expected within a small budget, or when asked (--fresh). ax_fresh.py snapshot print the file table (JSON) — axiomcode-build stores it ax_fresh.py status [--json] fresh | stale (+ the changed files) | building | no graph ax_fresh.py kick start the worker if the graph is stale; never waits ax_fresh.py wait [] kick, then wait up to for a fresh graph + ax_fresh.py query -- run a query verb stale-while-revalidate: marked rows, a wait only when + it matters (#1595) ax_fresh.py baseline [] when HEAD moved, wait for the baseline to follow it (changed, test-impact) ax_fresh.py worker the worker itself (what kick detaches) ax_fresh.py lock take the build lock on an fd the calling shell holds open ax_fresh.py count the source files of each language, walked as the parser walks Environment: AXIOMCODE_NO_REFRESH=1 turns every trigger off; AXIOMCODE_REFRESH_DEBOUNCE (seconds, default 2) -is the quiet window; AXIOMCODE_FRESH_WAIT (seconds, default 10) is how long a query verb waits; AXIOMCODE_BUILD_WAIT +is the quiet window; AXIOMCODE_FRESH_WAIT (seconds, default 30) 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 +takes, up to AXIOMCODE_FRESH_MAX (default 600); AXIOMCODE_BUILD_WAIT (seconds, default 900) is how long a query that finds no graph waits for a build that is running rather than starting its own; AXIOMCODE_NO_GITIGNORE=1 watches (and indexes) directories git ignores.""" import re, errno, hashlib, json, os, subprocess, sys, time @@ -52,14 +57,45 @@ 'python': ('pyproject.toml', 'setup.cfg', 'setup.py'), 'csharp': ('global.json', 'Directory.Build.props'), } -# the directories the parser skips (extract.ts's per-language excludeDirs), plus tool output nobody parses. -# A directory pruned here that the parser DOES read only costs a missed trigger; one read here that the -# parser skips costs a rebuild on every compile. `packages` is skipped for C# only: in a JavaScript -# monorepo it is where the source is. +# TOOL OUTPUT NOBODY PARSES: what the non-source scan of `impact` (axiomcode-impact) leaves out. NOT what the file table +# prunes: that is SKIP below, per language. PRUNE_ALL = {'.git', '.hg', '.svn', '.axiomcode', 'node_modules', 'bower_components', 'dist', 'build', 'out', 'coverage', '.next', '.nuxt', '.turbo', '.cache', '.yarn', '.venv', 'venv', 'site-packages', '__pycache__', '.tox', '.mypy_cache', '.pytest_cache', 'target', '.gradle', '.idea', '.vs'} -PRUNE = {'csharp': PRUNE_ALL | {'obj', 'bin', 'packages'}} +# WHAT EACH LANGUAGE'S PARSER SKIPS, and nothing more (#1594). The file table watches exactly the files the parser +# reads: a directory pruned here that the parser DOES read is an edit no refresh ever sees and that `index` calls up to +# date, which is what one shared list did to `out`, `build`, `target` and `coverage` in Python and C# (and `coverage` +# in Java). A directory watched here that the parser skips costs only a needless rebuild. Each set is the parser's own: +# java parser/src/constants/consts.ts EXCLUDED_DIRS, and every directory whose name starts with a dot +# typescript parser/src/constants/typescript-constants.ts TS_SKIP_DIRECTORIES, and dot directories +# javascript parser/src/constants/javascript-constants.ts JS_SKIP_DIRECTORIES +# python parser/src/workflows/python/python-project-analyzer.ts DEFAULT_EXCLUDES, `*.egg-info`, and under a +# `build` directory the setuptools output shapes (BUILD_ARTIFACT_SHAPE); a `build` package itself is read +# csharp parser/src/workflows/csharp/csharp-project-analyzer.ts DEFAULT_EXCLUDES +# .axiomcode (the graph's own directory) and .git are pruned for every language: the build puts .axiomcode in +# .git/info/exclude, so the parser skips it too. Directories git ignores are pruned for every language (git_ignored_dirs). +SKIP = { + 'java': frozenset({'node_modules', '.git', '.idea', '.vscode', 'dist', 'build', 'target', 'out', '__pycache__', + '.pytest_cache', 'venv', 'env'}), + 'typescript': frozenset({'node_modules', '.git', 'dist', 'build', 'out', 'coverage', '.next', '.nuxt', '.turbo', + '.cache', '.yarn', 'bower_components'}), + 'javascript': frozenset({'node_modules', 'bower_components', '.git', 'dist', 'build', 'out', 'coverage', '.next', + '.nuxt', '.turbo', '.cache', '.yarn'}), + 'python': frozenset({'__pycache__', '.git', 'node_modules', '.venv', 'venv', '.tox', 'dist', '.eggs', '.mypy_cache', + '.pytest_cache', '_build', 'site-packages'}), + 'csharp': frozenset({'obj', 'bin', '.git', 'node_modules', 'packages', '.vs'}), +} +SKIP_HIDDEN = {'java', 'typescript'} # the parsers that skip every directory named . +ALWAYS = frozenset({'.axiomcode', '.git'}) +PY_BUILD_ARTIFACT = re.compile(r'^(lib(\.|$)|temp\.|scripts-|bdist\.)') + +def prunes(lang, name, parent=''): + """True when the parser of `lang` never enters a directory called `name` (whose parent directory is `parent`)""" + if name in ALWAYS: return True + if lang not in SKIP: return name in PRUNE_ALL + if name in SKIP[lang] or (lang in SKIP_HIDDEN and name.startswith('.')): return True + if lang == 'python': return name.endswith('.egg-info') or (parent == 'build' and bool(PY_BUILD_ARTIFACT.match(name))) + return False def out_dir(repo): return os.path.join(repo, '.axiomcode', 'out') def table_path(repo): return os.path.join(out_dir(repo), 'files.json') @@ -107,17 +143,19 @@ def watched(root, lang): # before every query; the tree is walked once now, a directory only one language prunes (C#'s obj/bin/packages) # entered for the others and its files kept from that one. The files yielded are the same set. langs = [l for l in lang.split(',') if l] if lang else [''] - common = set.intersection(*[set(PRUNE.get(l, PRUNE_ALL)) for l in langs]) - extra = {l: set(PRUNE.get(l, PRUNE_ALL)) - common for l in langs} spec = [(l, EXT.get(l, ()), NAMES.get(l, ())) for l in langs] hidden = {root: frozenset()} # the languages that pruned each directory walked ignored = git_ignored_dirs(root); real = os.path.realpath(root) # a .gitignore'd directory is pruned for every language for d, subdirs, files in os.walk(root): off = hidden.pop(d, frozenset()) rd = os.path.join(real, os.path.relpath(d, root)) if ignored else d - subdirs[:] = [s for s in subdirs if s not in common and not all(l in off or s in extra[l] for l in langs) - and not (ignored and os.path.normpath(os.path.join(rd, s)) in ignored)] - for s in subdirs: hidden[os.path.join(d, s)] = off | {l for l in langs if s in extra[l]} + parent, keep = os.path.basename(d), [] + for s in subdirs: + if ignored and os.path.normpath(os.path.join(rd, s)) in ignored: continue + o = off | {l for l in langs if prunes(l, s, parent)} + if all(l in o for l in langs): continue + keep.append(s); hidden[os.path.join(d, s)] = o + subdirs[:] = keep for f in files: for l, exts, names in spec: if l in off: continue @@ -442,17 +480,213 @@ def refreshed(repo): if st.get('checked'): d['checked_at'] = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(st['checked'])); d['checked_by'] = st.get('checked_by') or st.get('reason', '') return d -def note(s): - """one line for an answer given from a graph that is behind the files, or '' when it is not""" +def edited(s): + """the files a stale graph predates: changed, added and removed since it was built""" + return list(s.get('changed', [])) + list(s.get('added', [])) + list(s.get('removed', [])) + +def note(s, marked=None): + """one line for an answer given from a graph that is behind the files, or '' when it is not. `marked` is how many + of the answer's rows lie in those files (and carry the mark), None when the answer was not looked at""" if s.get('state') not in ('stale', 'building'): return '' - files = s.get('changed', []) + s.get('added', []) + s.get('removed', []) + files = edited(s) + if not files: return '' head = ', '.join(files[:5]) + (f" … +{len(files) - 5}" if len(files) > 5 else '') + rows = ('' if marked is None else + f"; {marked} row(s) lie in those files and are marked {MARK.strip()}: read them for their current text" if marked else + "; no row of this answer lies in those files") if s.get('failed'): why = s.get('failed_reason') or '' return (f"graph refresh: the last rebuild FAILED" + (f" — {why}" if why else '') + f" (see {s['failed']}); this answer is from the " - f"previous graph, which predates edits to {head}") + f"previous graph, which predates edits to {head}" + rows) return (f"graph refresh: {'rebuilding' if s['state'] == 'building' else 'queued'} — this answer is from the previous graph, " - f"which predates edits to {head}; read those files for their current text") + f"which predates edits to {head}" + (rows if marked is not None else '; read those files for their current text') + + "; --fresh waits for the rebuild") + +# ── STALE-WHILE-REVALIDATE (#1595) ────────────────────────────────────────────────────────────────────────────────── +# A query never blocks on a refresh by default: it answers from the last good graph and says precisely what is stale. +# Every row whose declaration or call site lies in a file edited, added or removed since the graph was built is marked; +# rows from untouched files are exactly as current as the graph and carry nothing. It WAITS only when that matters and +# will pay off: the answer (a row, or the name asked about) touches an edited file, the rebuild is not compiling the +# engine's rules, and the last build of this repository says it will be done within AXIOMCODE_FRESH_WAIT seconds +# (default 30). A fixed wait (10 s) was shorter than every rebuild measured, so it was spent and the answer came from +# the old graph anyway. --fresh (MCP fresh=true) waits for the rebuild whatever it costs, saying so as it goes. +MARK = ' (may be out of date)' +_CODE_LINE = re.compile(r'^\s*(\d+ )?\| ') # a line of quoted source (context --source): never marked +_TOKEN = re.compile(r'[\w.$+@/-]+') +_LOC_KEYS = ('at', 'declared_at', 'call_at', 'file', 'path', 'site', 'location') + +class Stale: + """the set of edited files, and whether a path printed in an answer is one of them. Answers print paths relative to + the repository (or to --src), so a path matches when one ends with the other at a '/'""" + def __init__(self, files): + self.files = [f.replace(os.sep, '/') for f in files] + self.base = {f.rsplit('/', 1)[-1] for f in self.files} + + def hit(self, token): + t = token.strip('./').split(':', 1)[0] if token else '' + if not t or t.rsplit('/', 1)[-1] not in self.base: return False + return any(t == f or f.endswith('/' + t) or t.endswith('/' + f) for f in self.files) + + def line(self, text): + return any(self.hit(t) for t in _TOKEN.findall(text)) + +def mark_text(text, stale): + """(text with every row that names an edited file marked, rows marked, lines naming one at all)""" + out, n, seen = [], 0, 0 + for l in text.split('\n'): + if l.strip() and not _CODE_LINE.match(l) and stale.line(l): + seen += 1 + if not l.lstrip().startswith(('next:', 'verified:', 'bound:')) and MARK not in l: l += MARK; n += 1 + out.append(l) + return '\n'.join(out), n, seen + +def mark_json(obj, stale): + """the same for a --json answer: a row (an object with a location) in an edited file gets "stale": true, and prose + lines are marked as the text is. Returns rows marked""" + n = 0 + if isinstance(obj, dict): + if any(isinstance(obj.get(k), str) and stale.hit(obj[k]) for k in _LOC_KEYS): + obj['stale'] = True; n += 1 + for k, v in obj.items(): + if k == 'prose' and isinstance(v, list): + obj[k] = [mark_text(x, stale)[0] if isinstance(x, str) else x for x in v] + elif isinstance(v, (dict, list)): n += mark_json(v, stale) + elif isinstance(obj, list): + for v in obj: n += mark_json(v, stale) + return n + +def mark_answer(out, stale, as_json): + """(the answer with its stale rows marked, rows marked, whether it touches an edited file at all)""" + if as_json: + try: obj = json.loads(out) + except ValueError: obj = None + if isinstance(obj, dict): + n = mark_json(obj, stale) + return json.dumps(obj, indent=1, ensure_ascii=False) + '\n', n, n > 0 or stale.line(out) + text, n, seen = mark_text(out, stale) + return text, n, seen > 0 + +_FLAG_VALUE = {'--depth', '--in', '--limit', '--kind', '--page', '--budget', '--tests-in', '--range', '--paths', '--from', '--out'} + +def query_names(verb, args, repo): + """the names a query asks about (impact's targets, path's endpoints, context's --from), as written""" + names, i, pos = [], 0, [] + while i < len(args): + a = args[i] + if a in _FLAG_VALUE: + if a == '--from' and i + 1 < len(args): names.append(args[i + 1]) + 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 != '*'] + return names + +def names_in_edits(repo, names, stale): + """True when a name asked about is declared or written in an edited file: a target given as file:line in one, or a + name whose last part appears as a word in one (a declaration just added is in no graph yet)""" + words = set() + for n in names: + if stale.hit(n): return True + w = re.split(r'[.#:/$()<>,\s]+', n.strip('()')) + w = [x for x in w if re.match(r'^\w+$', x)] + if w: words.add(w[-1]) + if not words: return False + pat = re.compile(r'\b(' + '|'.join(re.escape(w) for w in sorted(words)) + r')\b') + for f in stale.files: + try: + with open(os.path.join(repo, f), 'rb') as fh: text = fh.read(4 << 20).decode('utf-8', 'replace') + except OSError: continue + if pat.search(text): return True + return False + +def build_seconds(repo): + """(seconds the last build of this repository took to a usable graph, whether it compiled the engine's rules), from + what axiomcode-build records, or (None, False)""" + try: + f = open(os.path.join(out_dir(repo), 'build-seconds')).read().split() + return float(f[0]), len(f) > 1 and f[1] == '1' + except (OSError, ValueError, IndexError): return None, False + +def compiling(repo): + """True when the build running now is compiling the engine's rules: minutes, never waited for by default""" + st = read_state(repo); log = os.path.join(repo, '.axiomcode', 'build.log') + try: + if os.path.getmtime(log) + 1 < (st.get('started') or 0): return False # the log of an earlier build + with open(log, errors='replace') as fh: return 'compiling souffle program' in fh.read(1 << 16) + except OSError: return False + +def expected_left(repo): + """seconds until the refresh in flight is expected to swap in its graph, from the last build's duration; None when + there is nothing to estimate from (no build recorded, or the last one compiled rules, which a refresh does not)""" + secs, compiled = build_seconds(repo) + if secs is None or compiled: return None + st = read_state(repo) + if st.get('state') == 'building' and st.get('started'): return secs - (time.time() - st['started']) + return secs + float(os.environ.get('AXIOMCODE_REFRESH_DEBOUNCE') or 2) + +def wait_fresh(repo, seconds, say=False): + """wait until the graph matches the files again (the refresh swapped in its graph), a rebuild fails, or `seconds` + pass. With `say`, a progress line on stderr every 10 s: a wait is never silent. True when the graph is current""" + end = t0 = time.time(); end += seconds; last = t0 + while True: + s = status(repo) + if s['state'] in ('fresh', 'no graph', 'unknown') or not edited(s): return True + if s.get('failed'): return False + if s['state'] == 'stale': kick(repo, 'a query') + now = time.time() + if now >= end: return False + if say and now - last >= 10: + left = expected_left(repo) + print(f"waiting for the graph to refresh: {int(now - t0)} s so far" + (f", about {max(1, int(left))} s to go" if left and left > 0 else '') + + (" (compiling the engine's rules first)" if compiling(repo) else '') + " …", file=sys.stderr, flush=True) + last = now + time.sleep(0.3) + +def query(repo, verb, argv, fresh=False): + """run a query verb (argv) against the last good graph, the stale-while-revalidate way (above). Returns its exit code""" + def run(): + return subprocess.run(argv, stdout=subprocess.PIPE) + def passthrough(): os.execvp(argv[0], argv) + if not enabled(repo): passthrough() + s = status(repo) + if s['state'] == 'unknown': kick(repo, 'a query'); passthrough() + if s['state'] in ('fresh', 'no graph') or not edited(s): passthrough() + if not s.get('failed'): kick(repo, 'a query') + as_json = '--json' in argv + if fresh and not s.get('failed'): + left = expected_left(repo) + print(f"waiting for the graph to refresh (--fresh): {len(edited(s))} file(s) changed since the graph was built" + + (f", the last build took {int(build_seconds(repo)[0])} s" if left is not None else '') + " …", file=sys.stderr, flush=True) + if wait_fresh(repo, float(os.environ.get('AXIOMCODE_FRESH_MAX') or 600), say=True): passthrough() + s = status(repo) + stale = Stale(edited(s)); r = run() + out = r.stdout.decode('utf-8', 'replace') + marked, n, touched = mark_answer(out, stale, as_json) + touched = touched or names_in_edits(repo, query_names(verb, argv, repo), stale) + if touched and not fresh and not s.get('failed'): + budget = float(os.environ.get('AXIOMCODE_FRESH_WAIT') or 30) + left = expected_left(repo) + if budget > 0 and not compiling(repo) and (left is None or left <= budget): + print(f"waiting for the graph to refresh: this answer touches {', '.join(stale.files[:3])}" + (' …' if len(stale.files) > 3 else '') + + f", edited since the graph was built; up to {int(budget)} s" + (f" (the last build took {int(build_seconds(repo)[0])} s)" if left is not None else '') + " …", + file=sys.stderr, flush=True) + if wait_fresh(repo, budget, say=True): passthrough() + s = status(repo); stale = Stale(edited(s)) + if edited(s): + r = run(); out = r.stdout.decode('utf-8', 'replace') + marked, n, _ = mark_answer(out, stale, as_json) + else: passthrough() + if as_json: + try: + obj = json.loads(marked) + if isinstance(obj, dict): + obj['freshness'] = dict(state=s['state'], edited=edited(s), rows_marked=n, **({'failed': s['failed']} if s.get('failed') else {})) + marked = json.dumps(obj, indent=1, ensure_ascii=False) + '\n' + except ValueError: pass + sys.stdout.write(marked); sys.stdout.flush() + msg = note(s, n) + if msg: print(msg, file=sys.stderr) + return r.returncode def main(argv): if len(argv) < 2: print(__doc__); return 2 @@ -478,7 +712,7 @@ def main(argv): # how many extensionless python scripts are under repo: added to the build's .py count when it picks a language n = 0 for d, subdirs, files in os.walk(repo): - subdirs[:] = [x for x in subdirs if x not in PRUNE_ALL] + subdirs[:] = [x for x in subdirs if not prunes('python', x, os.path.basename(d))] n += sum(1 for f in files if python_script(os.path.join(d, f), f)) print(n); return 0 if cmd == 'uptodate': @@ -502,6 +736,10 @@ def main(argv): if n: print(n, file=sys.stderr) return 0 if cmd == 'worker': return worker(repo) + if cmd == 'query': + # ax_fresh.py query -- : what the dispatcher runs a query verb through + rest = argv[argv.index('--') + 1:] + return query(repo, argv[3], rest, fresh=bool(os.environ.get('AXIOMCODE_FRESH'))) if cmd == 'wait': s = wait(repo, float(argv[3]) if len(argv) > 3 else float(os.environ.get('AXIOMCODE_FRESH_WAIT') or 10)) n = note(s) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode index 2a0baeb0f..aa4d00913 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode @@ -3,7 +3,7 @@ # # axiomcode index [] [--lang java|typescript|python|javascript|csharp] [--src ] [--library [,…]] # the pipeline: parser → engine → .axiomcode/out/graph.sqlite (+ index). defaults to the current directory. -# axiomcode context "" [] [--in [,…]]… [--budget N] [--source] [--json] +# axiomcode context "" [] [--in [,…]]… [--budget N] [--source] [--json] [--fresh] # THE FRONT DOOR: the files and callables a task touches, from the problem statement — when there is no name # to ask about yet. Deterministic: task terms scored against the graph's vocabulary by inverse document # frequency, tests demoted, the closure walked from the best seed per term and ranked by nearest hop. @@ -14,7 +14,7 @@ # the graph as one page. Runs the pipeline only when the repo has no up-to-date graph (~1 s otherwise). # defaults to the current directory; the page goes to /.axiomcode/graph/graph.html, or to --out: a folder # gets /.html, a .html path is used as given. The page embeds the sources, so a node opens its code. -# axiomcode path [] [--every | --paths N] [--all] [--limit N] [--in ] [--json] +# axiomcode path [] [--every | --paths N] [--all] [--limit N] [--in ] [--json] [--fresh] # the shortest chain of calls from A to B per reached target, and with --every all the routes; through what? # '*' as one endpoint: path '*' X = everything that can reach X, with the entry points among them; path X '*' = everything X reaches. Endpoints exactly as written in the code: Owner.method, # method (free function or any owner), Type (all its methods), file.ts:123, file.py. Datalog over the graph; every @@ -22,7 +22,7 @@ # Every hop carries the LINE THE CALL IS WRITTEN ON, how certain the edge is, and what kind of call it is — # an invocation, a construction, a constructor chain, a super call, a decorator, a property access, a method # reference — in one vocabulary across all five languages. A hop that is not a call is marked and not counted. -# axiomcode impact […] [] [--tests] [--depth N] [--in ] [--limit N] [--json] [--kind k] +# axiomcode impact […] [] [--tests] [--depth N] [--in ] [--limit N] [--json] [--kind k] [--fresh] # what has to be looked at again when a declaration changes: a method, a field / constant / enum member, a type, a # parameter (Owner.m(p)), a type parameter (Type), a local (Owner.m:v), Type. / Type.. Prints what # must change with it (overrides, subtypes), what directly touches it with WHY and how sure ([resolved] / [in scope] / @@ -45,7 +45,8 @@ # # A repo in several languages is indexed in all of them, one graph each, and the query verbs ask every graph (the main # 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. +# --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. case "$0" in */*) H="${0%/*}";; *) H=.;; esac; H="$(cd "${H:-/}" && pwd)" # no `dirname`: see bin/axiomcode # PYTHON UNDER ANOTHER NAME (#1331). Every verb runs `python3`, which a python.org install on Windows does not @@ -96,6 +97,7 @@ while [ $# -gt 0 ]; do --lang) export AXIOMCODE_LANG="$2"; shift 2 ;; --src) export AXIOMCODE_SRC="$2"; shift 2 ;; --library) export AXIOMCODE_LIBRARY="$2"; shift 2 ;; + --fresh) export AXIOMCODE_FRESH=1; shift ;; *) ARGS+=("$1"); shift ;; esac done @@ -104,25 +106,29 @@ done # build a repository called `--help`. One branch is cheaper than three, and a verb added tomorrow gets # it without anyone remembering to. case "${ARGS[0]:-}" in -h|--help) if [ -n "$cmd" ] && [ "${#ARGS[@]}" = 1 ]; then verbhelp "$cmd"; exit $?; fi ;; esac -# A QUERY READS A CURRENT GRAPH (#1305). Before a query verb runs, the graph is checked against the files it was built -# from; when one differs, the background refresher is started and the answer waits for it up to AXIOMCODE_FRESH_WAIT -# seconds (default 10, enough for a small repository's rebuild). Past that it is given from the previous graph, with -# one line on stderr naming the files that graph predates. The repository is the last argument that is a directory, -# as the verbs themselves take it, else the working directory. No graph: nothing here, the verb builds its first one. +# A QUERY NEVER BLOCKS ON A REFRESH BY DEFAULT (#1305, #1595). Before a query verb answers, the graph is checked against +# the files it was built from. When one differs the background refresher is started and the answer comes from the last +# good graph at once, every row that lies in a changed file marked "(may be out of date)" and one line on stderr naming +# the files. It waits (up to AXIOMCODE_FRESH_WAIT, default 30 s) only when the answer touches a changed file and the last +# build says the refresh will finish within that; never on a compile of the engine's rules. --fresh waits for the +# refresh, saying how it goes, and answers from the new graph. ax_fresh.py query does all of it; a fresh graph costs one +# check and an exec. The repository is the last argument that is a directory, as the verbs themselves take it, else the +# working directory. No graph: nothing here, the verb builds its first one. # `changed` and `test-impact` ask about an edit, against the graph of the baseline (kept aside by a refresh), so they do -# not wait for the graph; they wait only when HEAD moved and the baseline has to follow it. +# not wait for the graph; they wait only when HEAD moved and the baseline has to follow it. `graph` builds (axiomcode-build) +# before it draws, so it is current by construction. FR=.; for a in ${ARGS[@]+"${ARGS[@]}"}; do [ -d "$a" ] && FR="$a"; done +# A REPOSITORY IN SEVERAL LANGUAGES has one graph per language (.axiomcode/lang/ beside the main one): a query +# asks every one of them (ax_langs.py), so no language's code is left out of an answer. A graph named in +# AXIOMCODE_GRAPH was chosen by the caller and is asked alone. +Q=""; [ -d "$FR/.axiomcode/lang" ] && [ -z "${AXIOMCODE_GRAPH:-}" ] && Q="$H/ax_langs.py" # (-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 - context|path|impact|graph) python3 "$H/ax_fresh.py" wait "$FR" "${AXIOMCODE_FRESH_WAIT:-10}" || true ;; + context|path|impact) exec python3 "$H/ax_fresh.py" query "$FR" "$cmd" -- 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 fi -# A REPOSITORY IN SEVERAL LANGUAGES has one graph per language (.axiomcode/lang/ beside the main one): a query -# asks every one of them (ax_langs.py), so no language's code is left out of an answer. A graph named in -# AXIOMCODE_GRAPH was chosen by the caller and is asked alone. -Q=""; [ -d "$FR/.axiomcode/lang" ] && [ -z "${AXIOMCODE_GRAPH:-}" ] && Q="$H/ax_langs.py" case "$cmd" in index|build) exec bash "$H/axiomcode-build" ${ARGS[@]+"${ARGS[@]}"} ;; context) exec python3 ${Q:+"$Q" "$FR"} "$H/axiomcode-context" ${ARGS[@]+"${ARGS[@]}"} ;; diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build index d37ac77d0..feeeb48af 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build @@ -107,6 +107,7 @@ if ! python3 "$H/ax_fresh.py" lock 9 --try; then [ -n "${AXIOMCODE_BACKGROUND:-}" ] || echo "a graph build is already running for $REPO (the background refresh); waiting for it …" python3 "$H/ax_fresh.py" lock 9 || { echo "could not take the build lock ($REPO/.axiomcode/build.lock)"; exit 75; } fi +BUILD_T0=$SECONDS # how long a build takes to a usable graph: a query decides from it whether to wait (#1595) GD="$(cd "$REPO" && git rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)" [ -n "$GD" ] && mkdir -p "$GD/info" && { grep -qx '.axiomcode' "$GD/info/exclude" 2>/dev/null || echo '.axiomcode' >> "$GD/info/exclude"; } || true STAMP="$(cd "$REPO" && git rev-parse HEAD 2>/dev/null || echo nogit)-$LANGS-${AXIOMCODE_SRC:-.}-${AXIOMCODE_LIBRARY:-nolib}" @@ -331,6 +332,9 @@ if [ -n "$INDEXED_TREE" ]; then echo "$INDEXED_TREE" > "$OUT/indexed-tree"; else set_base "$INDEXED_TREE" point "$DB" || { restore; exit 1; }; rm -f "$OUT/.live.sqlite" if [ -f "$OUT/.files.json.new" ]; then mv "$OUT/.files.json.new" "$OUT/files.json"; else rm -f "$OUT/files.json"; fi +# the graph now matches the files: record how long that took, and whether it included compiling the engine's rules (a +# one-time cost a refresh does not pay, so such a duration predicts nothing) +printf '%s %s\n' "$((SECONDS - BUILD_T0))" "$(grep -q 'compiling souffle program' "$REPO/.axiomcode/build.log" 2>/dev/null && echo 1 || echo 0)" > "$OUT/build-seconds" 2>/dev/null || true keep_base_graph for l in ${READY[@]+"${READY[@]}"}; do mkdir -p "$LANGDIR/$l/out"; swap "$OUT/$l/graph.sqlite" "$LANGDIR/$l/out/graph.sqlite" || true # a rename: atomic diff --git a/skills/axiomcode/SKILL.md b/skills/axiomcode/SKILL.md index 2f60e2f02..b2923a602 100644 --- a/skills/axiomcode/SKILL.md +++ b/skills/axiomcode/SKILL.md @@ -32,8 +32,12 @@ Read / Grep results as `graph: …` lines. Rules that decide whether an answer means anything: - **Never re-run `index` on an existing graph** "to make sure" or after your own edit. The graph refreshes itself in - the background after edits, with the flags it was built with; a query waits a few seconds for it. An answer that - still predates an edit says so on a `graph refresh:` line naming the files: read those for their current text. + the background after edits, with the flags it was built with. A query does not wait for it: it answers from the last + graph, names the edited files on a `graph refresh:` line, and marks every row that lies in one `(may be out of date)` + (`"stale": true` in `--json`); unmarked rows are current. Read a marked row's file for its current text. It waits + briefly on its own only when the answer touches an edited file and the rebuild is nearly done. +- **Before a delete or a rename, ask with `--fresh`** (MCP `fresh=true`): it waits for the rebuild, printing its + progress, and answers from a graph that includes every edit. A manual `index` with different flags (e.g. dropping `--library`) rebuilds a worse graph over the good one. - A repo in several languages is indexed in all of them, one graph each, and every query asks each graph; calls are not followed from one language to another. `--lang` restricts it, `--src src` narrows it; `--library ` so calls into dependencies diff --git a/tests/README.md b/tests/README.md index 4a61ff2e3..564ddea19 100644 --- a/tests/README.md +++ b/tests/README.md @@ -39,6 +39,11 @@ One check needs no graph and is its own script: engine the last build used before PATH (the refresh runs from a hook, with the hook's PATH), follows a Windows npm shim, and names every place it looked when it finds none + python3 tests/freshness.py an answer from a graph older than an edit marks the rows in edited files (text and + --json) and nothing else; it waits only when the answer touches an edited file + and the refresh is expected within the budget, never on a rules compile; + --fresh waits and MCP takes fresh=true; the file table prunes exactly what each + parser skips, so an edit under out/ or build/ is seen (#1594, #1595; no engine) python3 tests/refresh_races.py what a refresh after an edit can leave behind, each with a control: a broken graph pointer is repaired and keeps the baseline; a query mid-build waits for it and starts no build; a failed build never leaves the pointer dangling; a failed diff --git a/tests/freshness.py b/tests/freshness.py new file mode 100644 index 000000000..6f190cb71 --- /dev/null +++ b/tests/freshness.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python3 +"""tests/freshness.py: an answer from a graph older than an edit says exactly what is stale, and waits only when it pays. + +A query never blocks on a refresh by default (#1595): it answers from the last good graph, marks every row that lies in +a file edited since, and waits only when the answer touches such a file and the refresh is expected within a small +budget. And the refresher watches every file the parser reads, so no edit leaves the graph stale unseen (#1594). + + prune the directories the file table prunes are the ones each language's parser skips, read from the parser's + own constants; an edit under out/, build/, target/ or coverage/ is watched where that parser reads it, and + a directory the parser skips is not + marks a row in an edited file is marked "(may be out of date)" in text and "stale": true in --json; a row in an + untouched file, a quoted line of code and the next: line are not; a same-named file in another + directory is not taken for the edited one + wait the decision, each with its control: an answer touching an edited file waits for a refresh expected within + the budget and answers from the new graph; it does not wait when the last build says it will take longer, + when the running build is compiling rules, or when the answer touches no edited file + fresh --fresh waits whatever the estimate, with progress on stderr, and answers unmarked; a current graph is + answered with no mark and no note + mcp the MCP tools take fresh=true and pass --fresh, and the CLI's --fresh is written fresh=True in an answer + +No engine: the wait checks drive `ax_fresh.py query` with a stand-in verb, and a stand-in refresh that brings the file +table up to date after two seconds, the way a real one swaps in its graph. + + python3 tests/freshness.py [-v] +""" +import importlib.util, json, os, re, shutil, subprocess, sys, tempfile, threading, time + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SCRIPTS = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts') +PARSER = os.path.join(ROOT, 'parser', 'src') +sys.path.insert(0, SCRIPTS) +import ax_fresh + +RESULTS = [] +VERBOSE = '-v' in sys.argv + + +def check(name, ok, detail=''): + RESULTS.append((name, bool(ok))) + print(('ok ' if ok else 'FAIL ') + name + ('' if ok and not VERBOSE else f"\n {str(detail)[:1200]}" if detail != '' else '')) + + +def write(root, rel, text='x\n'): + p = os.path.join(root, rel); os.makedirs(os.path.dirname(p), exist_ok=True) + open(p, 'w').write(text) + + +# ── prune ───────────────────────────────────────────────────────────────────────────────────────────────────────── +def ts_list(rel, name): + """the string literals of `name`'s array or Set in a parser source file""" + text = open(os.path.join(PARSER, rel)).read() + m = re.search(re.escape(name) + r'\s*=\s*(?:new Set\()?\[(.*?)\]', text, re.S) + return set(re.findall(r"'([^']+)'", m.group(1))) if m else set() + + +def prune_checks(): + parser = { + 'java': ts_list('constants/consts.ts', 'EXCLUDED_DIRS'), + 'typescript': ts_list('constants/typescript-constants.ts', 'TS_SKIP_DIRECTORIES'), + 'javascript': ts_list('constants/javascript-constants.ts', 'JS_SKIP_DIRECTORIES'), + 'python': ts_list('workflows/python/python-project-analyzer.ts', 'DEFAULT_EXCLUDES'), + 'csharp': ts_list('workflows/csharp/csharp-project-analyzer.ts', 'DEFAULT_EXCLUDES'), + } + for lang, names in parser.items(): + check(f"prune: {lang} prunes exactly the directories its parser skips ({len(names)} names read from the parser)", + len(names) >= 5 and set(ax_fresh.SKIP[lang]) == names, sorted(set(ax_fresh.SKIP[lang]) ^ names)) + work = tempfile.mkdtemp(prefix='axiomcode-prune-') + try: + tree = { + 'python': ['app/out/calc.py', 'app/build/calc.py', 'target/t.py', 'coverage/c.py', 'build/lib/gen.py', + 'build/frontend.py', '.venv/v.py', 'pkg.egg-info/e.py', 'dist/d.py', 'app/ok.py'], + 'java': ['src/main/java/app/coverage/Calc.java', 'src/main/java/app/Ok.java', 'out/O.java', 'target/T.java', + 'build/B.java', '.hidden/H.java'], + 'csharp': ['src/Shop/build/Calc.cs', 'src/Shop/out/O.cs', 'target/T.cs', 'coverage/C.cs', 'src/Shop/Ok.cs', + 'src/Shop/obj/G.cs', 'bin/B.cs', 'packages/P.cs'], + } + read = { # what the parser reads (True) or skips (False), by its own lists + 'python': [True, True, True, True, False, True, False, False, False, True], + 'java': [True, True, False, False, False, False], + 'csharp': [True, True, True, True, True, False, False, False], + } + for lang, files in tree.items(): + root = os.path.join(work, lang) + for f in files: write(root, f) + got = {os.path.relpath(p, root) for p in ax_fresh.watched(root, lang)} + want = {f for f, r in zip(files, read[lang]) if r} + check(f"prune: {lang} watches every file its parser reads under out/build/target/coverage, and none it skips", + got == want, f"missing {sorted(want - got)} extra {sorted(got - want)}") + # the control that makes the above mean something: a directory a language's parser skips stays pruned + check("prune: a node_modules or .axiomcode directory is pruned for every language", + all(ax_fresh.prunes(l, 'node_modules') and ax_fresh.prunes(l, '.axiomcode') for l in ax_fresh.SKIP)) + # the file table sees an edit there: the whole point of #1594 + root = os.path.join(work, 'python') + table = dict(lang='python', src='', files=ax_fresh.snapshot(root, 'python', root)) + open(os.path.join(root, 'app/out/calc.py'), 'a').write('\ndef audit():\n return 3\n') + c = ax_fresh.changes(root, table) + check("prune: an edit under app/out/ makes the graph stale", c and c[0] == ['app/out/calc.py'], c) + finally: + shutil.rmtree(work, ignore_errors=True) + + +# ── marks ───────────────────────────────────────────────────────────────────────────────────────────────────────── +ANSWER = """change: OrderService.total [method] +reads or uses it (3 callable(s)): + [resolved] report shop/report.py:5 - calls it + [resolved] run shop/api.py:5 - calls it + [resolved] other other/api.py:7 - calls it + source files (2, nearest first): shop/report.py (1), shop/api.py (1) + 12 | x = run(shop/api.py:5) +next: read shop/orders.py:2, then only these place(s) bound to it: shop/api.py:5, shop/report.py:5""" + + +def marks_checks(): + stale = ax_fresh.Stale(['shop/api.py']) + text, n, touched = ax_fresh.mark_answer(ANSWER, stale, False) + lines = text.split('\n') + marked = [l for l in lines if l.endswith(ax_fresh.MARK)] + check("marks: the row in the edited file is marked, and the line listing it", n == 2 and touched and + any('run shop/api.py:5' in l for l in marked) and any('source files' in l for l in marked), text) + check("marks: a row in an untouched file, a same-named file elsewhere, a quoted code line and next: are not", + not any(k in l for l in marked for k in ('report shop', 'other/api.py', '12 |', 'next:')), text) + none = ax_fresh.mark_answer(ANSWER, ax_fresh.Stale(['shop/orders_test.py']), False) + check("marks: an edit to a file the answer never names marks nothing and does not count as touching it", + none[1] == 0 and not none[2] and ax_fresh.MARK not in none[0], none) + j = dict(direct=[dict(display='run', at='shop/api.py:5'), dict(display='report', at='shop/report.py:5')], + answers=[dict(hops=[dict(declared_at='shop/orders.py:2', call_at='shop/api.py:5')])], + files=[dict(file='shop/api.py'), dict(file='shop/report.py')], prose=[' run shop/api.py:5']) + out, n, touched = ax_fresh.mark_answer(json.dumps(j), stale, True) + o = json.loads(out) + check("marks: --json rows in the edited file carry \"stale\": true, the others none, prose is marked", + [r.get('stale') for r in o['direct']] == [True, None] and o['answers'][0]['hops'][0].get('stale') is True + and [r.get('stale') for r in o['files']] == [True, None] and o['prose'][0].endswith(ax_fresh.MARK) and n == 3, out) + + +# ── wait / fresh ────────────────────────────────────────────────────────────────────────────────────────────────── +DRIVER = r""" +import os, sys +sys.path.insert(0, sys.argv[1]); import ax_fresh +ax_fresh.kick = lambda *a, **k: True # the stand-in refresh is the test's own thread +sys.exit(ax_fresh.query(os.path.realpath(sys.argv[2]), sys.argv[3], sys.argv[5:], fresh=bool(os.environ.get('AXIOMCODE_FRESH')))) +""" + + +def fake_repo(work, name, build_seconds='3 0', log=''): + repo = os.path.join(work, name) + write(repo, 'shop/api.py', 'def run(items):\n return total(items)\n') + write(repo, 'shop/report.py', 'def report(items):\n return total(items)\n') + write(repo, '.axiomcode/out/graph.sqlite', '') + table = dict(lang='python', lang_auto=False, src='', src_arg='', library='', built=time.time(), + files=ax_fresh.snapshot(repo, 'python', repo)) + json.dump(table, open(os.path.join(repo, '.axiomcode/out/files.json'), 'w')) + if build_seconds: write(repo, '.axiomcode/out/build-seconds', build_seconds + '\n') + json.dump(dict(state='building', started=time.time()), open(os.path.join(repo, '.axiomcode/refresh.json'), 'w')) + if log: write(repo, '.axiomcode/build.log', log) + return repo + + +def ask(repo, verb_out, after=None, env=None, verb='impact', args=('OrderService.total',)): + """run `ax_fresh.py query` over a stand-in verb that prints verb_out; `after` seconds in, the stand-in refresh + brings the file table up to date. Returns (stdout, stderr, seconds)""" + driver = os.path.join(os.path.dirname(repo), 'driver.py'); open(driver, 'w').write(DRIVER) + edit = os.path.join(repo, 'shop/api.py'); open(edit, 'a').write('\ndef audit(items):\n return total(items)\n') + def refresh(): + time.sleep(after) + t = json.load(open(os.path.join(repo, '.axiomcode/out/files.json'))) + t['files'] = ax_fresh.snapshot(repo, 'python', repo) + json.dump(t, open(os.path.join(repo, '.axiomcode/out/files.json'), 'w')) + open(os.path.join(repo, 'swapped'), 'w').write('new graph\n') + th = threading.Thread(target=refresh) if after is not None else None + if th: th.start() + verb_cmd = [sys.executable, '-c', "import os,sys; print(open('swapped').read().strip() if os.path.exists('swapped') else sys.argv[1])", verb_out] + e = dict(os.environ, AXIOMCODE_FRESH_WAIT='30', **(env or {})) + for k in ('AXIOMCODE_NO_REFRESH', 'AXIOMCODE_GRAPH'): e.pop(k, None) + if 'AXIOMCODE_FRESH' not in (env or {}): e.pop('AXIOMCODE_FRESH', None) + t0 = time.time() + r = subprocess.run([sys.executable, driver, SCRIPTS, repo, verb, '--', *verb_cmd, *args, repo], cwd=repo, + capture_output=True, text=True, env=e, timeout=120) + took = time.time() - t0 + if th: th.join() + return r.stdout, r.stderr, took + + +ROWS = " [resolved] run shop/api.py:5 - calls it\n [resolved] report shop/report.py:5 - calls it" + + +def wait_checks(): + work = tempfile.mkdtemp(prefix='axiomcode-fresh-') + try: + out, err, took = ask(fake_repo(work, 'soon'), ROWS, after=2) + check(f"wait: an answer touching an edited file waits for a refresh expected within the budget ({took:.1f}s), " + "and answers from the new graph", out.strip() == 'new graph' and 1.5 < took < 20 and 'graph refresh:' not in err + and 'waiting for the graph to refresh' in err, (out, err)) + out, err, took = ask(fake_repo(work, 'long', build_seconds='300 0'), ROWS, after=2) + check(f"wait: it does not wait when the last build says the refresh takes longer than the budget ({took:.1f}s)", + took < 1.5 and 'run shop/api.py:5 - calls it' + ax_fresh.MARK in out and 'report shop/report.py:5 - calls it\n' in out + '\n' + and '1 row(s) lie in those files' in err, (out, err)) + out, err, took = ask(fake_repo(work, 'compile', log='▶ compiling souffle program (cache miss)...\n'), ROWS, after=2) + check(f"wait: it never waits on a build that is compiling the engine's rules ({took:.1f}s)", + took < 1.5 and ax_fresh.MARK in out, (out, err)) + out, err, took = ask(fake_repo(work, 'untouched'), " [resolved] report shop/report.py:5 - calls it", after=2, + args=('report',)) + check(f"wait: it does not wait when neither the answer nor the name asked about touches an edited file ({took:.1f}s)", + took < 1.5 and ax_fresh.MARK not in out and 'no row of this answer lies in those files' in err, (out, err)) + out, err, took = ask(fake_repo(work, 'named'), "no declaration named audit", after=2, args=('audit',)) + check(f"wait: a name that is written only in an edited file (a declaration just added) waits ({took:.1f}s)", + out.strip() == 'new graph' and 1.5 < took < 20, (out, err)) + out, err, took = ask(fake_repo(work, 'fresh', build_seconds='300 0'), ROWS, after=2, env=dict(AXIOMCODE_FRESH='1')) + check(f"fresh: --fresh waits whatever the estimate, says so, and answers from the new graph ({took:.1f}s)", + out.strip() == 'new graph' and 1.5 < took < 20 and '(--fresh)' in err and 'graph refresh:' not in err, (out, err)) + repo = fake_repo(work, 'current'); driver = os.path.join(work, 'driver.py') + r = subprocess.run([sys.executable, driver, SCRIPTS, repo, 'impact', '--', sys.executable, '-c', f"print({ROWS!r})"], + capture_output=True, text=True, env={k: v for k, v in os.environ.items() if k != 'AXIOMCODE_NO_REFRESH'}) + check("fresh: a graph that matches the files answers with no mark and no note", + r.stdout.strip() == ROWS.strip() and not r.stderr.strip(), (r.stdout, r.stderr)) + finally: + shutil.rmtree(work, ignore_errors=True) + + +# ── mcp ─────────────────────────────────────────────────────────────────────────────────────────────────────────── +def mcp_checks(): + spec = importlib.util.spec_from_file_location('axiomcode_mcp_server', os.path.join(ROOT, 'plugins', 'axiomcode', 'mcp', 'server.py')) + m = importlib.util.module_from_spec(spec) + import io, contextlib + with contextlib.redirect_stderr(io.StringIO()): spec.loader.exec_module(m) + check("mcp: context, path and impact take fresh", all('fresh' in m.PARAMS.get(t, []) for t in ('axiomcode_context', 'axiomcode_path', 'axiomcode_impact')), + {t: m.PARAMS.get(t) for t in ('axiomcode_context', 'axiomcode_path', 'axiomcode_impact')}) + seen = [] + m.run = lambda args, *a, **k: seen.append(args) or '' + fn = lambda name: getattr(m, name) + try: + fn('axiomcode_impact')(['X'], repo='.', fresh=True); fn('axiomcode_path')('A', 'B', fresh=True); fn('axiomcode_context')('t', fresh=True) + fn('axiomcode_impact')(['X'], repo='.') + except TypeError as e: + seen.append(str(e)) + check("mcp: fresh=true passes --fresh to the CLI, and only when asked", + len(seen) == 4 and all('--fresh' in s for s in seen[:3]) and '--fresh' not in seen[3], seen) + check("mcp: an answer's --fresh is written as the parameter", 'fresh=True' in m.mcp_words('ask again with --fresh to wait'), + m.mcp_words('ask again with --fresh to wait')) + + +if __name__ == '__main__': + prune_checks(); marks_checks(); wait_checks(); mcp_checks() + bad = [n for n, ok in RESULTS if not ok] + print(f"\n{len(RESULTS) - len(bad)} of {len(RESULTS)} passed" + (f"; FAILED: {len(bad)}" if bad else '')) + sys.exit(1 if bad or not RESULTS else 0) diff --git a/tests/latency.py b/tests/latency.py index 897b9fd56..99f2abdb1 100644 --- a/tests/latency.py +++ b/tests/latency.py @@ -133,9 +133,9 @@ def walk_checks(): def per_language(lang): out = set() for l in lang.split(','): - exts, names, prune = ax_fresh.EXT.get(l, ()), ax_fresh.NAMES.get(l, ()), ax_fresh.PRUNE.get(l, ax_fresh.PRUNE_ALL) + exts, names = ax_fresh.EXT.get(l, ()), ax_fresh.NAMES.get(l, ()) for d, subdirs, files in os.walk(work): - subdirs[:] = [s for s in subdirs if s not in prune] + subdirs[:] = [s for s in subdirs if not ax_fresh.prunes(l, s, os.path.basename(d))] for f in files: if f.endswith(exts) or f in names or (l == 'java' and os.path.basename(d) == 'services' and 'META-INF' in d) \ or (l == 'python' and ax_fresh.python_script(os.path.join(d, f), f)): diff --git a/tests/refresh.py b/tests/refresh.py index bc4beed3b..787aebff7 100644 --- a/tests/refresh.py +++ b/tests/refresh.py @@ -11,6 +11,8 @@ signature a second edit, a parameter added to the callee, is reported as a signature change, again both ways restored the files put back as committed: a clean `git status` is NOT taken for fresh, the graph is rebuilt, and the added function is gone from it + marks an answer from the previous graph marks the rows in the edited file (text and --json), and + --fresh waits for the rebuild and answers unmarked (#1595) single flight a burst of triggers produces one rebuild, and queries issued while it runs all answer hooks the refresh hook, fed an edit event, starts the refresher and prints nothing @@ -114,6 +116,17 @@ def check(ok, why, detail=''): open(f, 'w').write(text) before_tree = tree() X = ask_changed(repo, quiet) + # stale-while-revalidate (#1595): with no budget to wait, the answer comes from the previous graph at once and + # marks the rows that lie in the edited file, and only those; --json carries the same as "stale": true + nowait = dict(env, AXIOMCODE_FRESH_WAIT='0', AXIOMCODE_REFRESH_DEBOUNCE='10') # the refresh it starts waits 10 s: both asks see the old graph + sw = sh(repo, AX, 'impact', helper, '.', env=nowait) + rows = [l for l in sw.stdout.splitlines() if L['edit'] in l and not l.lstrip().startswith(('next:', 'verified:'))] + check(rows and all(l.endswith('(may be out of date)') for l in rows) and 'row(s) lie in those files' in sw.stderr, + f'{lang}: an answer from the previous graph marks every row in the edited file ({len(rows)})', sw.stdout + sw.stderr) + js = json.loads(sh(repo, AX, 'impact', helper, '.', '--json', env=nowait).stdout or '{}') + direct = [r for r in js.get('direct', []) if L['edit'] in r.get('at', '')] + check(direct and all(r.get('stale') is True for r in direct) and L['edit'] in js.get('freshness', {}).get('edited', []), + f'{lang}: and --json says "stale": true on those rows', json.dumps(js)[:1500]) p = sh(repo, AX, 'path', added, helper, '.', env=env) check(p.returncode == 0 and 'verified' in p.stdout and 'graph refresh:' not in p.stderr, f'{lang}: after an edit, a query finds the added function and its call (the refresher ran, the query waited)', p.stdout + p.stderr) @@ -133,7 +146,10 @@ def check(ok, why, detail=''): if L.get('sig_call'): text = text.replace(*L['sig_call']) open(f, 'w').write(text) X2 = ask_changed(repo, quiet) - sh(repo, AX, 'impact', helper, '.', env=env) + # --fresh waits for the rebuild even with no budget to wait on its own, and answers unmarked from the new graph + fr = sh(repo, AX, 'impact', helper, '.', '--fresh', env=dict(env, AXIOMCODE_FRESH_WAIT='0')) + check(fr.returncode == 0 and added in fr.stdout and '(may be out of date)' not in fr.stdout and 'graph refresh:' not in fr.stderr + and '(--fresh)' in fr.stderr, f'{lang}: impact --fresh waits for the rebuild and answers from the new graph', fr.stdout[-800:] + fr.stderr) Y2 = ask_changed(repo, quiet) # JavaScript also records a function declaration as a variable, and `changed` reports the edit as that # variable's (a gap of `changed`, the same with or without a refresh); the kind is not what is tested here