diff --git a/bin/axiomcode b/bin/axiomcode index 0c3265c78..e01ccc31b 100755 --- a/bin/axiomcode +++ b/bin/axiomcode @@ -57,7 +57,13 @@ if [ -L "$self" ]; then fi self="$r" fi -ROOT="$(d="$(cd "$(dirname "$self")" && pwd)"; while [ "$d" != / ] && { [ ! -f "$d/package.json" ] || [ ! -d "$d/graph" ]; }; do d="$(dirname "$d")"; done; echo "$d")" +# NO PROGRAM IS STARTED TO FIND IT: the parent of a path is taken by parameter expansion, not `dirname`. Every query goes +# through here, and under Git Bash on Windows each program started is a fork emulation that costs tens of milliseconds +# idle and far more on a busy machine; the walk used to start one per level, the verb check below four more. +case "$self" in */*) d="${self%/*}";; *) d=.;; esac +d="$(cd "${d:-/}" && pwd)" +while [ "$d" != / ] && { [ ! -f "$d/package.json" ] || [ ! -d "$d/graph" ]; }; do d="${d%/*}"; d="${d:-/}"; done +ROOT="$d" # No marker found means every path built from ROOT is nonsense; say so here rather than fail # later on a path the user cannot interpret. [ -d "$ROOT/graph" ] || { echo "axiomcode: cannot locate the package root from $0 (resolved to $self)" >&2; exit 2; } @@ -76,8 +82,20 @@ QUERY="$ROOT/plugins/axiomcode/skills/axiomcode/scripts/axiomcode" # `tests` is the frontend's alias for test-impact; `test` is THIS command's engine suite. One letter # apart, entirely different jobs, and putting both on one command is a trap — so the alias is not # routed here, and the name is refused below with both readings spelled out. -query_verbs(){ [ -f "$QUERY" ] && bash "$QUERY" --verbs 2>/dev/null | grep -vx tests || true; } -is_query_verb(){ query_verbs | grep -qx -- "$1"; } +# The list is read from the frontend's own dispatch table, the lines ` [|]) exec …` that its `--verbs` +# prints, so the two entry points still cannot drift -- but read here, in this shell, instead of by a second bash, a +# sed, a tr and two greps on every query. +QVERBS=() +load_verbs(){ + [ ${#QVERBS[@]} -gt 0 ] && return 0; [ -f "$QUERY" ] || return 0 + local l v x re='^ ([a-z|-]*)\) *exec ' + while IFS= read -r l || [ -n "$l" ]; do + [[ $l =~ $re ]] || continue; v="${BASH_REMATCH[1]}" + for x in ${v//|/ }; do [ "$x" = tests ] || QVERBS+=("$x"); done + done < "$QUERY" +} +query_verbs(){ load_verbs; [ ${#QVERBS[@]} -gt 0 ] && printf '%s\n' "${QVERBS[@]}"; return 0; } +is_query_verb(){ load_verbs; local x; for x in ${QVERBS[@]+"${QVERBS[@]}"}; do [ "$x" = "$1" ] && return 0; done; return 1; } usage(){ awk 'NR > 2 && /^# ─/ {exit} NR > 2' "$0" | sed 's/^# \{0,1\}//' if [ -f "$QUERY" ]; then diff --git a/plugins/axiomcode/mcp/find-python.js b/plugins/axiomcode/mcp/find-python.js index 9e47abfd7..56d19e36e 100644 --- a/plugins/axiomcode/mcp/find-python.js +++ b/plugins/axiomcode/mcp/find-python.js @@ -14,6 +14,9 @@ 'use strict'; const { spawnSync } = require('child_process'); const { which } = require('./which.js'); +const crypto = require('crypto'); +const fs = require('fs'); +const os = require('os'); const path = require('path'); const SHIM = path.join(__dirname, '..', 'skills', 'axiomcode', 'scripts', 'pyshim'); @@ -24,14 +27,67 @@ function candidates() { } // { cmd, exe } or { error }: cmd is how the candidate was named, exe the interpreter file it runs. +// THE PROBE IS REMEMBERED. It starts an interpreter, and it ran on every `axiomcode` command and on every hook, which fire +// on every tool call an agent makes: one Python start each time, before the work, and on a busy Windows machine that is +// the slowest process there is to start. Its answer depends only on what each candidate name resolves to on PATH, so it is +// kept in the temp directory under a key of PATH and AXIOMCODE_PYTHON, with the file every candidate it tried resolved to +// (and that file's mtime). A later call re-resolves those names -- a stat per directory, no process -- and probes again +// only when one of them resolves elsewhere, or the file changed: a Python installed, removed or upgraded. +// AXIOMCODE_NO_PYTHON_CACHE=1 probes every time. +function onPath(name) { + if (process.platform === 'win32' || path.isAbsolute(name) || /[\\/]/.test(name)) { + const p = process.platform === 'win32' ? which(name) : name; + if (!p) return null; + try { return [p, fs.statSync(p).mtimeMs]; } catch { return null; } + } + for (const d of (process.env.PATH || '').split(path.delimiter)) { + if (!d || !path.isAbsolute(d)) continue; + const p = path.join(d, name); + try { const st = fs.statSync(p); if (st.isFile()) return [p, st.mtimeMs]; } catch { /* not here */ } + } + return null; +} + +function cacheFile() { + const key = crypto.createHash('sha1').update([process.platform, process.arch, process.env.PATH || process.env.Path || '', + process.env.AXIOMCODE_PYTHON || ''].join('\0')).digest('hex').slice(0, 16); + return path.join(os.tmpdir(), `axiomcode-python-${key}.json`); +} + +function cached() { + if (process.env.AXIOMCODE_NO_PYTHON_CACHE) return null; + try { + const c = JSON.parse(fs.readFileSync(cacheFile(), 'utf8')); + for (const [name, was] of c.seen) { + const now = onPath(name); + if (JSON.stringify(now) !== JSON.stringify(was)) return null; + } + if (!fs.statSync(c.exe).isFile()) return null; + return { cmd: c.cmd, exe: c.exe }; + } catch { return null; } +} + +function remember(found, seen) { + if (process.env.AXIOMCODE_NO_PYTHON_CACHE) return; + try { + const f = cacheFile(), tmp = `${f}.${process.pid}`; + fs.writeFileSync(tmp, JSON.stringify({ cmd: found.cmd, exe: found.exe, seen })); + fs.renameSync(tmp, f); + } catch { /* a read-only temp directory only costs the probe next time */ } +} + function findPython() { + const hit = cached(); + if (hit) return hit; + const seen = []; for (const cmd of candidates()) { + seen.push([cmd[0], onPath(cmd[0])]); const exe0 = which(cmd[0]); // PATH only: never a python.exe in the current directory if (!exe0) continue; const r = spawnSync(exe0, [...cmd.slice(1), '-c', 'import sys; print(sys.executable)'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true, timeout: 15000 }); const exe = r.status === 0 && String(r.stdout).trim(); - if (exe) return { cmd, exe }; + if (exe) { const found = { cmd, exe }; remember(found, seen); return found; } } return { error: 'axiomcode needs Python 3, and no python3, python' + (process.platform === 'win32' ? ' or py -3' : '') + ' on PATH runs.\n • install it (https://www.python.org/downloads/), or\n' + diff --git a/plugins/axiomcode/mcp/server.py b/plugins/axiomcode/mcp/server.py index ca7a1af92..680fa58ea 100755 --- a/plugins/axiomcode/mcp/server.py +++ b/plugins/axiomcode/mcp/server.py @@ -41,11 +41,23 @@ def _timer(interval): def run(args, cwd=None, timeout=900): for a in args[1:]: if os.path.isdir(a) and os.path.isdir(os.path.join(a, '.axiomcode')): SEEN.add(os.path.realpath(a)) + # A QUERY ON A REPOSITORY WITH NO GRAPH YET does not hold the call for the length of a first build (minutes; past this + # server's timeout it came back as a bare "Error executing tool"): the build is started in the background and the + # answer says what stage it is at (ax_contract.ensure_graph). `index` is asked for a build and still waits for it. + env = dict(os.environ, AXIOMCODE_BUILD_NOWAIT='1') if args and args[0] != 'index' else None try: - r = subprocess.run([BASH, AX, *args], cwd=cwd or None, capture_output=True, text=True, timeout=timeout) + r = subprocess.run([BASH, AX, *args], cwd=cwd or None, capture_output=True, text=True, timeout=timeout, env=env) except OSError as e: return (f"axiomcode could not start bash ({BASH}): {e}. On Windows it needs the bash that comes with " "Git for Windows; install it, or set AXIOMCODE_BASH to its bin\\bash.exe.") + except subprocess.TimeoutExpired: + repo = next((a for a in reversed(args[1:]) if os.path.isdir(a)), cwd or os.getcwd()) + try: + sys.path.insert(0, os.path.dirname(AX)); import ax_contract, ax_fresh + if ax_fresh.building(os.path.realpath(repo)): return ax_contract.building_note(os.path.realpath(repo)) + except Exception: pass + return (f"axiomcode {args[0] if args else ''} did not answer within {timeout} s. Nothing was changed; " + f"see {os.path.join(repo, '.axiomcode', 'build.log')} if a build was running, and ask again.") out = (r.stdout or '') + (('\n' + r.stderr.strip()) if r.returncode and r.stderr.strip() else '') # an answer given from a graph that predates some edit says so, and names the files (#1305) if not r.returncode: out += ''.join('\n' + l for l in (r.stderr or '').splitlines() if l.startswith('graph refresh:')) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_contract.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_contract.py index 49005f3ff..5da78a88b 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_contract.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_contract.py @@ -23,7 +23,7 @@ 5. STATE THE BOUND. Say what the answer cannot see — the relations this graph does not encode — so a partial list is not read as a complete one. """ -import collections, difflib, os, re, subprocess, sys +import collections, difflib, os, re, subprocess, sys, time SPLIT = re.compile(r'[^A-Za-z0-9]+') CAMEL = re.compile(r'[A-Z]+(?![a-z])|[A-Z][a-z0-9]*|[a-z0-9]+') @@ -388,11 +388,32 @@ def ensure_graph(repo, db): sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))); import ax_fresh rr = os.path.realpath(repo); auto = os.environ.get('AXIOMCODE_AUTOBUILD') if ax_fresh.building(rr): - ax_fresh.wait_build(rr, float(os.environ.get('AXIOMCODE_BUILD_WAIT') or 900) if auto else 0) + # a build is running: never a silent wait. Under the MCP server answer at once with the stage it is at; from a + # shell wait for it, printing the stage as it moves; either way, use the graph the moment it exists + if auto and not os.environ.get('AXIOMCODE_BUILD_NOWAIT'): + print(f"a graph build is already running for {repo}; waiting for it …", file=sys.stderr) + _follow(rr, lambda: ax_fresh.building(rr)) if os.path.exists(db): return True + if auto and os.environ.get('AXIOMCODE_BUILD_NOWAIT'): + _BUILD_NOTE.append(building_note(rr)); return False if not auto or os.environ.get('AXIOMCODE_GRAPH'): return False build = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'axiomcode-build') if not os.path.exists(build): return False + import ax_fresh + bash = os.environ.get('AXIOMCODE_BASH') or 'bash' + # NEVER A SILENT WAIT. A first build takes minutes (a whole-program solve, and on a machine that has never built this + # language, a one-time compile of its rules), and it used to run with nothing said after its first line, so a caller + # could not tell a build from a hang. Under the MCP server (AXIOMCODE_BUILD_NOWAIT) the build is started in the + # background and the call answers within AXIOMCODE_BUILD_WAIT seconds either way: with the graph when it was quick, + # else with the stage the build is at, so the agent is told to ask again rather than left waiting past the host's + # timeout. From a shell the call waits for it, printing the stage as it moves. A call that finds a build already + # running (another query's, or an `axiomcode index`) reports on that one rather than queueing a second behind it. + nowait = bool(os.environ.get('AXIOMCODE_BUILD_NOWAIT')) + if ax_fresh.building(repo): + if nowait: _BUILD_NOTE.append(building_note(repo)); return False + print(f"a graph build is already running for {repo}; waiting for it …", file=sys.stderr) + _follow(repo, lambda: ax_fresh.building(repo)) + return os.path.exists(db) env = None if ax_fresh.has_graph(rr): # a graph WAS built here and its pointer is broken: a repair, which keeps the baseline `changed` and test-impact @@ -402,12 +423,71 @@ def ensure_graph(repo, db): env = dict(os.environ, AXIOMCODE_KEEP_BASE='1') else: print(f"no graph for {repo} yet — building one (this is the only slow call; later ones read it) …", file=sys.stderr) - r = subprocess.run([os.environ.get('AXIOMCODE_BASH') or 'bash', build, repo], env=env) - return r.returncode == 0 and os.path.exists(db) + if nowait: + os.makedirs(os.path.join(repo, '.axiomcode'), exist_ok=True) + log = open(os.path.join(repo, '.axiomcode', 'first-build.log'), 'w') + kw = dict(start_new_session=True) if os.name != 'nt' else dict(creationflags=0x00000008 | 0x00000200) # DETACHED | NEW_GROUP, as ax_fresh.kick + p = subprocess.Popen([bash, build, repo], stdin=subprocess.DEVNULL, stdout=log, stderr=log, close_fds=True, env=env, **kw) + # until the build holds its lock a second query would see no build and start another, so that much is always waited + up = time.time() + 10 + while p.poll() is None and not ax_fresh.building(repo) and time.time() < up: time.sleep(0.05) + end = time.time() + float(os.environ.get('AXIOMCODE_BUILD_WAIT') or 60) + while p.poll() is None and time.time() < end: time.sleep(0.5) + if p.poll() is None: _BUILD_NOTE.append(building_note(repo)); return False + return p.returncode == 0 and os.path.exists(db) + p = subprocess.Popen([bash, build, repo], env=env) + _follow(repo, lambda: p.poll() is None) + return p.returncode == 0 and os.path.exists(db) + + +_BUILD_NOTE = [] +TICK = 20.0 + + +def build_stage(repo): + """(stage, done, of): the last `▶` step the running build's log names, and how many of its languages it has + started solving, from .axiomcode/build.log""" + stage, langs, solving = '', [], 0 + try: text = open(os.path.join(repo, '.axiomcode', 'build.log'), errors='replace').read() + except OSError: text = '' + for l in text.splitlines(): + if not l.startswith('\u25b6 '): continue + stage = l[2:].strip() + if stage.startswith('languages:'): langs = stage.split(':', 1)[1].split() + elif stage.startswith('solving '): solving += 1 + return stage, solving, len(langs) + + +def building_note(repo): + """one line: a graph is being built, what it is doing and for how long, and what to do meanwhile""" + try: took = time.time() - os.path.getmtime(os.path.join(repo, '.axiomcode', 'build.lock')) + except OSError: took = 0 + stage, n, of = build_stage(repo) + where = (f"language {n} of {of}, " if of and n else '') + (stage or 'starting') + if 'compiling souffle program' in stage: + where += ' — a one-time compile of this language\'s rules on this machine, a few minutes; later builds reuse it' + return (f"the graph for {repo} is being built ({where}; {int(took // 60)}m{int(took % 60):02d}s so far). " + f"Nothing else is needed: ask again in a minute or two, or read the code directly meanwhile. " + f"Progress: {os.path.join(repo, '.axiomcode', 'build.log')}") + + +def _follow(repo, running): + """wait while running(), printing the build's stage to stderr whenever it moves, and every TICK seconds at least""" + last, said = None, time.time() + while running(): + time.sleep(0.5) + stage, n, of = build_stage(repo) + if (stage and stage != last) or time.time() - said >= TICK: + try: took = time.time() - os.path.getmtime(os.path.join(repo, '.axiomcode', 'build.lock')) + except OSError: took = 0 + print(f" … {'language %d of %d, ' % (n, of) if of and n else ''}{stage or 'starting'} ({int(took)}s)", file=sys.stderr, flush=True) + last, said = stage, time.time() def no_graph(repo, db): """what to say when there is still no graph after ensure_graph has had its turn.""" + if _BUILD_NOTE: + return _BUILD_NOTE[-1] if os.environ.get('AXIOMCODE_GRAPH'): return f"no graph at {db} (AXIOMCODE_GRAPH is set, so nothing was built into it)" if os.environ.get('AXIOMCODE_AUTOBUILD'): diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py index a813c7f0e..dc29b8820 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py @@ -103,21 +103,27 @@ def git_ignored_dirs(root): def watched(root, lang): """every file the parser of `lang` reads under root; `lang` may be a comma list (a repository in several languages, each with its own graph), and a file two of them read is yielded once""" - if ',' in lang: - seen = set() - for l in lang.split(','): - for p in watched(root, l): - if p not in seen: seen.add(p); yield p - return - exts, names, prune = EXT.get(lang, ()), NAMES.get(lang, ()), PRUNE.get(lang, PRUNE_ALL) - ignored = git_ignored_dirs(root); real = os.path.realpath(root) + # ONE WALK FOR EVERY LANGUAGE. A repository in five languages was walked five times on every freshness check, that is + # 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 prune and not (ignored and os.path.normpath(os.path.join(rd, s)) in ignored)] + 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]} for f in files: - if f.endswith(exts) or f in names or (lang == 'java' and os.path.basename(d) == 'services' and 'META-INF' in d) \ - or (lang == 'python' and python_script(os.path.join(d, f), f)): - yield os.path.join(d, f) + for l, exts, names in spec: + if l in off: continue + 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 python_script(os.path.join(d, f), f)): + yield os.path.join(d, f); break def digest(p): h = hashlib.sha1() diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_nonsource.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_nonsource.py new file mode 100644 index 000000000..8e6bffa5a --- /dev/null +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_nonsource.py @@ -0,0 +1,161 @@ +"""ax_nonsource.py — the text files the index does not read as source, and where a name is written in them, without +opening every one of them on every query. + +`impact` looks for the changed name in files the compiler never sees (a mapper XML, a services file, a properties file, +a build script, a spec), because a rename breaks those at run time. It used to find them by walking the tree and +OPENING every candidate file twice per query: once to tell text from binary, once to search it. On this repository that +is ~2,100 files and 0.6 s of every impact query; on a machine whose antivirus scans each open, or on a busy one, the +opens are what the query waits on, and they are the same files every time. + +So the scan is kept per graph in /out/dl/nonsource.sqlite: + + files(rel, size, mtime_ns, kind) what each candidate file was when it was last read: kind 0 text, 1 binary + tok(tok, rel) the words written in each text file: every maximal run of [\\w.$-] characters, + and each of its prefixes that ends before a '.' + +A query still walks the tree (so a new or deleted file is seen at once) and stats every candidate, but opens only a file +whose size or mtime moved since it was read, and searches only the files whose words include a name asked for. A name +can match only at the start of such a run (the search requires no [\\w.$-] before it) and must end where the run ends or +at a '.', which is exactly the set stored, so the files skipped are files the search could not have matched: the answer +is the one the full scan gives. A file modified in the last two seconds is not cached (its mtime may not have ticked +yet); a name with a character outside [\\w.$-] falls back to searching every file; any error with the cache falls back to +the full scan. +""" +import os, re, sqlite3, time + +VERSION = '2' +WORD = re.compile(r'[\w.$-]+') +NAME_OK = re.compile(r'[\w.$-]+') +MAX_SIZE = 512 * 1024 +CAP = 4000 +RACY = 2.0 + + +def words(text): + """every run of [\\w.$-] and each of its prefixes that ends before a '.': where a whole-token name can match""" + out = set() + for m in WORD.finditer(text): + r = m.group(0); out.add(r) + i = r.find('.', 1) + while i > 0: + out.add(r[:i]); i = r.find('.', i + 1) + return out + + +def _classify(fp): + """0 text, 1 binary, None unreadable -- the same test the full scan applies (a NUL byte in the first 2 KB)""" + try: + with open(fp, 'rb') as fh: head = fh.read(2048) + except OSError: return None + return 1 if b'\0' in head else 0 + + +class NonSource: + def __init__(self, repo, cache_dir, indexed, skip_dir, skip_ext): + self.repo, self.cache_dir, self.indexed, self.skip_dir, self.skip_ext = repo, cache_dir, indexed, skip_dir, skip_ext + self._files = None; self._uncached = set(); self.con = None + if cache_dir and not os.environ.get('AXIOMCODE_NO_SCAN_CACHE'): + try: + os.makedirs(cache_dir, exist_ok=True) + self.con = sqlite3.connect(os.path.join(cache_dir, 'nonsource.sqlite'), timeout=5) + self.con.executescript("""CREATE TABLE IF NOT EXISTS meta(key TEXT PRIMARY KEY, value TEXT); + CREATE TABLE IF NOT EXISTS files(id INTEGER PRIMARY KEY, rel TEXT UNIQUE, size INTEGER, mtime INTEGER, kind INTEGER); + CREATE TABLE IF NOT EXISTS tok(tok TEXT, fid INTEGER, PRIMARY KEY (tok, fid)) WITHOUT ROWID;""") + v = self.con.execute("SELECT value FROM meta WHERE key = 'version'").fetchone() + if not v or v[0] != VERSION: + with self.con: + self.con.execute("DELETE FROM files"); self.con.execute("DELETE FROM tok") + self.con.execute("INSERT OR REPLACE INTO meta VALUES ('version', ?)", (VERSION,)) + except sqlite3.Error: + self.con = None + + def files(self): + """every text file in the tree that is NOT source the index read, sorted (Impact.nonsource_files)""" + if self._files is not None: return self._files + try: + if self.con is not None: return self._files_cached() + except (sqlite3.Error, OSError): + self.con = None + self._files = self._files_full(); return self._files + + def _files_full(self): + out = [] + for root, dirs, files in os.walk(self.repo): + dirs[:] = [d for d in dirs if d not in self.skip_dir and not d.startswith('.axiomcode')] + for fn in files: + ext = os.path.splitext(fn)[1].lower() + if ext in self.skip_ext: continue + fp = os.path.join(root, fn) + if os.path.relpath(fp, self.repo).replace(os.sep, '/') in self.indexed: continue + try: + if os.path.getsize(fp) > MAX_SIZE: continue + with open(fp, 'rb') as fh: head = fh.read(2048) + if b'\0' in head: continue + except OSError: continue + out.append(os.path.relpath(fp, self.repo).replace(os.sep, '/')) + if len(out) > CAP: break + self._uncached = set(out) + return sorted(out) + + def _files_cached(self): + known = {r[0]: (r[1], r[2], r[3], r[4]) for r in self.con.execute("SELECT rel, size, mtime, kind, id FROM files")} + out, seen, put, now = [], set(), [], time.time() + for root, dirs, files in os.walk(self.repo): + dirs[:] = [d for d in dirs if d not in self.skip_dir and not d.startswith('.axiomcode')] + for fn in files: + ext = os.path.splitext(fn)[1].lower() + if ext in self.skip_ext: continue + fp = os.path.join(root, fn) + rel = os.path.relpath(fp, self.repo).replace(os.sep, '/') + if rel in self.indexed: continue + try: st = os.stat(fp) + except OSError: continue + if st.st_size > MAX_SIZE: continue + seen.add(rel) + k = known.get(rel) + if k and k[0] == st.st_size and k[1] == st.st_mtime_ns: kind = k[2] + else: + kind = _classify(fp) + if kind is None: continue + if now - st.st_mtime < RACY: self._uncached.add(rel) + else: + text = None + if kind == 0: + try: text = open(fp, errors='replace').read() + except OSError: continue + put.append((rel, st.st_size, st.st_mtime_ns, kind, text)) + if kind: continue + out.append(rel) + if len(out) > CAP: break + with self.con: + stale = [known[r][3] for r in known if r not in seen] + [known[p[0]][3] for p in put if p[0] in known] + for fid in stale: + self.con.execute("DELETE FROM files WHERE id = ?", (fid,)); self.con.execute("DELETE FROM tok WHERE fid = ?", (fid,)) + for rel, size, mt, kind, text in put: + fid = self.con.execute("INSERT INTO files(rel, size, mtime, kind) VALUES (?, ?, ?, ?)", (rel, size, mt, kind)).lastrowid + if text is not None: self.con.executemany("INSERT INTO tok VALUES (?, ?)", ((w, fid) for w in words(text))) + self._files = sorted(out); return self._files + + def hits(self, names): + """-> [(name, file, line)] where one of `names` is written as a whole token in a non-source file""" + names = sorted({n for n in names if n and len(n) > 2}, key=len, reverse=True) + if not names: return [] + pat = re.compile(r'(? 2000: break + return hits diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode index e5eb2e729..2a0baeb0f 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode @@ -46,7 +46,7 @@ # 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. -H="$(cd "$(dirname "$0")" && pwd)" +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 # provide, and which on a desktop Windows is the Store placeholder. Started from node (the `axiomcode` command, the diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index e1406ebb4..ebf4d9da3 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -567,7 +567,10 @@ class Impact: die(f"{whole}: no declaration of {bare} takes ({', '.join(want)}). " f"It is declared as: " + ' · '.join(sigs or ['(unknown)']) + " — write one of those, or drop the parentheses for all of them") SOURCE_EXT = {'.java','.py','.ts','.tsx','.js','.jsx','.mjs','.cjs','.kt','.kts','.scala','.go','.rb','.cs','.class','.jar'} - SKIP_DIR = {'.git','node_modules','target','build','out','dist','.axiomcode','.idea','.gradle','venv','.venv','__pycache__','.mvn'} + # and tool output nobody writes a binding in: a framework's build cache (.next, .nuxt, .turbo), coverage reports, package + # manager caches. The refresher already ignores them (ax_fresh.PRUNE_ALL); scanned here, a dev server's build folder + # was thousands of files opened on every query, and a name in a compiled chunk is not a place a rename breaks. + SKIP_DIR = {'.git','node_modules','target','build','out','dist','.axiomcode','.idea','.gradle','venv','.venv','__pycache__','.mvn'} | __import__('ax_fresh').PRUNE_ALL # prose is not a run-time binding: a name in a changelog does not break when it is renamed PROSE_EXT = {'.md','.adoc','.rst','.log','.lock','.txt'} # `import` is not a call and the parser records no edge for it, so what a file imports is read from its own @@ -625,41 +628,22 @@ class Impact: if tgt and tgt != f: out.add((f, tgt)) return sorted(out) + def _nonsource(self): + """the non-source scan, cached per graph (ax_nonsource.py): a query opens only the files that changed since the + last one read them, and searches only those whose words include a name asked for. AXIOMCODE_NO_SCAN_CACHE=1 + scans every file, as before; the two give the same answer (tests/latency.py).""" + if not hasattr(self, '_ns'): + import ax_nonsource + indexed = {sy['file'] for sy in self.g.sym.values() if sy.get('file')} # a shebang script is source too (#1376) + self._ns = ax_nonsource.NonSource(self.g.repo, self.g.facts, indexed, self.SKIP_DIR, self.SOURCE_EXT | self.PROSE_EXT) + return self._ns def nonsource_files(self): """every text file in the tree that is NOT source the index read. A name bound from one of these (a mapper XML, a services file, a properties file, a build script, a spec) breaks at run time and the compiler is silent.""" - if hasattr(self, '_nsf'): return self._nsf - out = [] - indexed = {sy['file'] for sy in self.g.sym.values() if sy.get('file')} # a shebang script is source too (#1376) - for root, dirs, files in os.walk(self.g.repo): - dirs[:] = [d for d in dirs if d not in self.SKIP_DIR and not d.startswith('.axiomcode')] - for fn in files: - ext = os.path.splitext(fn)[1].lower() - if ext in self.SOURCE_EXT or ext in self.PROSE_EXT: continue - fp = os.path.join(root, fn) - if os.path.relpath(fp, self.g.repo).replace(os.sep, '/') in indexed: continue - try: - if os.path.getsize(fp) > 512 * 1024: continue - with open(fp, 'rb') as fh: head = fh.read(2048) - if b'\0' in head: continue - except OSError: continue - out.append(os.path.relpath(fp, self.g.repo).replace(os.sep, '/')) - if len(out) > 4000: break - self._nsf = sorted(out); return self._nsf + return self._nonsource().files() def nonsource_hits(self, names): """-> [(name, file, line)] where one of `names` is written as a whole token in a non-source file""" - names = sorted({n for n in names if n and len(n) > 2}, key=len, reverse=True) - if not names: return [] - pat = re.compile(r'(? 2000: break - return hits + return self._nonsource().hits(names) KEYFILE_EXT = {'.properties','.yml','.yaml','.conf','.ini','.cfg','.env'} def config_key_sites(self, keys): """a key DEFINED or overridden in a settings file, matched on the canonical form so the spelling there @@ -1280,15 +1264,24 @@ class Impact: prog = self.program() # the compiled rules, or the interpreter if prog[0] == 'souffle' and not shutil.which('souffle'): die(dl_program.rules_unavailable('impact')) - prof('query facts written'); r = subprocess.run(prog + ['-F', F, '-D', O], capture_output=True, text=True); prof('souffle returned') - if r.returncode: die("souffle failed:\n" + r.stderr[-1200:]) - out = {} - for n in ('contract', 'direct', 'direct_edge', 'seed', 'seed_byname', 'reach', 'reach_sure', 'parent_up', 'test_near', 'test_hit', 'inherited_test', 'extbind', 'gen_fired', 'caller_handles', 'caller_unhandled', 'target_throws'): - rows = [] - for l in open(os.path.join(O, n + '.csv')): - p = l.rstrip('\n').split('\t') - if p[0] in QS: rows.append(p[1:] + [p[0]]) # … + the query id, so each row says which target it came from - out[n] = rows + # THE SOLVE IS A FUNCTION OF ITS FACTS. The same question on the same graph -- page 2 of an answer, a repeat, the + # impact `changed --impact` and `test-impact` ask again for one edit -- hands the rules the same fact files, so + # what they derived the first time is what they derive now: it is kept, keyed by the rules, the graph's fact + # export and every per-query fact, and read back instead of running the solver again. AXIOMCODE_NO_SOLVE_CACHE=1 runs it. + key = self.solve_key(prog, F, D); hit = self.solve_cached(D, key) + if hit is not None: + prof('solve read from the cache'); out = hit + else: + prof('query facts written'); r = subprocess.run(prog + ['-F', F, '-D', O], capture_output=True, text=True); prof('souffle returned') + if r.returncode: die("souffle failed:\n" + r.stderr[-1200:]) + out = {} + for n in ('contract', 'direct', 'direct_edge', 'seed', 'seed_byname', 'reach', 'reach_sure', 'parent_up', 'test_near', 'test_hit', 'inherited_test', 'extbind', 'gen_fired', 'caller_handles', 'caller_unhandled', 'target_throws'): + rows = [] + for l in open(os.path.join(O, n + '.csv')): + p = l.rstrip('\n').split('\t') + if p[0] in QS: rows.append(p[1:] + [p[0]]) # … + the query id, so each row says which target it came from + out[n] = rows + self.solve_store(D, key, out) out['_targets'] = QS # AXIOMCODE_DUMP_SOLVE=: record what the rules derived, so the SQL port can be developed and diffed # against real output without re-running a 26 s query for every iteration. @@ -1302,6 +1295,43 @@ class Impact: def souffle_include(self): return dl_program.souffle_include() + # the facts a query writes itself; every other file in its fact directory is the graph's export, linked in + PER_QUERY_WRITTEN = ('nonsource', 'qual_name', 'key_cap', 'key_use_cap', 'target', 'textuse', 'importuse', 'inside_target') + SOLVE_CACHE_MAX = 200 + def solve_key(self, prog, F, D): + """what a solve is a function of: the rules, the graph export (its stamp, and the size of each linked file, so a + relation left empty for this target kind is told apart), and the text of every fact this query wrote""" + import hashlib + h = hashlib.sha1() + h.update(dl_program.rules_id(os.path.join(HERE, 'dl', 'impact.dl')).encode()); h.update(b'\0') + for st in (os.path.join(D, 'stamp'), os.path.join(self.g.facts, 'stamp')): + try: h.update(open(st, 'rb').read()) + except OSError: h.update(b'-') + h.update(b'\0') + for n in sorted(os.listdir(F)): + p = os.path.join(F, n); h.update(n.encode()); h.update(b'\0') + if n[:-6] in self.PER_QUERY_WRITTEN: + with open(p, 'rb') as fh: h.update(fh.read()) + else: h.update(str(os.path.getsize(p)).encode()) + h.update(b'\0') + return h.hexdigest() + def solve_cached(self, D, key): + if os.environ.get('AXIOMCODE_NO_SOLVE_CACHE'): return None + try: + with open(os.path.join(D, 'solve', key + '.json')) as fh: return json.load(fh) + except (OSError, ValueError): return None + def solve_store(self, D, key, out): + if os.environ.get('AXIOMCODE_NO_SOLVE_CACHE'): return + d = os.path.join(D, 'solve') + try: + os.makedirs(d, exist_ok=True) + tmp = os.path.join(d, f'.{key}.{os.getpid()}') + with open(tmp, 'w') as fh: json.dump(out, fh) + os.replace(tmp, os.path.join(d, key + '.json')) + old = sorted((e for e in os.scandir(d) if e.name.endswith('.json')), key=lambda e: e.stat().st_mtime) + for e in old[:max(0, len(old) - self.SOLVE_CACHE_MAX)]: os.remove(e.path) + except OSError: pass + def program(self): """the rules compiled to a native binary once and cached by the program's hash — see dl_program.py, which every query program now shares, and which `axiomcode index` warms so that no query (least of all one under a hook's @@ -1440,6 +1470,7 @@ def main(argv): # warm. `axiomcode index` calls this: left to first use, that cost lands inside hooks/changes.py's timeout=14, # several queries at once, and the edit-time impact is lost exactly when a change is being made. g = G(repo); g.IN = IN; I = Impact(g); D = I.export() + I.nonsource_files() # and read the non-source files once, so no query opens them all (ax_nonsource.py) print(f"impact facts ready ({os.path.relpath(D, os.path.abspath(repo))})") return 0 if from_text is not None: diff --git a/tests/README.md b/tests/README.md index bcea31660..4a61ff2e3 100644 --- a/tests/README.md +++ b/tests/README.md @@ -49,6 +49,11 @@ One check needs no graph and is its own script: python3 tests/multi_language.py a repository in several languages is indexed in all of them and every query asks each graph: nothing dropped, a one-graph answer unchanged, an edit reported once, refreshed, upgraded from a one-language graph (builds real graphs, needs the engine) + python3 tests/latency.py what a query repeats on every call is done once, with the same answer: the non-source + scan cached per graph, a repeated impact solve read back, one tree walk for every language, + the verb and the Python found without starting programs, a first build that reports its + stage instead of holding the call (indexes a small project, so it needs the engine; + --no-engine for the rest) python3 tests/tiers.py every call_edges tier the schema documents is ranked, labelled and given a certainty by the frontend, so a new tier cannot read as the weakest claim diff --git a/tests/latency.py b/tests/latency.py new file mode 100644 index 000000000..51b89e0f6 --- /dev/null +++ b/tests/latency.py @@ -0,0 +1,310 @@ +#!/usr/bin/env python3 +"""tests/latency.py — what a query spends its time on is spent once, and the answer does not change for it. + +Measured on a 34k-line React/TypeScript app and on this repository, a query's time went to work repeated on every call +that gives the same result every time: opening every non-source file in the tree to look for the changed name (and a +dev server's build folder is thousands of them), running the impact rules again for a question already asked (page 2 +of the same answer), walking the tree once per language to decide the graph is fresh, starting a second bash, a sed, a +tr and two greps to decide a word is a verb, and starting Python once more just to ask it where it lives. And a query +on a repository with no graph yet waited, silently, for the whole first build. + +Each check here pins one of those, and every speed-up is checked against the slow path it replaces: the same answer, +byte for byte. + + scan the cached non-source scan finds exactly what the full scan finds, across edits, additions, deletions, + a binary file, a too-large file, dotted names and a name the word index cannot hold; and a warm query + opens only the files whose words include the name + skip a name written in a framework's build folder (.next) or a coverage report is not a binding; the same name + in a config file still is + walk the one-walk file table of a repository in several languages is the union of the per-language walks, + directories only C# prunes (obj, bin) included + solve impact answers the same with the solve cache as without it, a repeat reads it, and another question on + the same graph does not + verbs bin/axiomcode tells a verb from a build without starting a second bash, and knows the verbs the frontend + dispatches + python the node launchers' Python probe is remembered, and probed again when what a candidate resolves to moves + build a query under the MCP server on a repository with no graph answers within seconds, saying the graph is + being built and at what stage, and so does a second one while it builds; the answer comes once it is done + timeout an MCP call that outlives the server's timeout says so in words instead of raising + +The scan, walk, verbs and python checks need no engine; skip, solve and build index a small TypeScript project +(AXIOMCODE_ENGINE, as tests/run.py). + + python3 tests/latency.py [--no-engine] +""" +import builtins, importlib.util, json, os, shutil, subprocess, sys, tempfile, time + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SCRIPTS = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts') +sys.path.insert(0, SCRIPTS) +RESULTS = [] + + +def check(why, ok, detail=''): + RESULTS.append(ok) + print(('ok ' if ok else 'FAIL ') + why) + if not ok and detail: print(' ' + str(detail)[-1500:].replace('\n', '\n ')) + + +def write(root, rel, text, mode='w'): + p = os.path.join(root, rel); os.makedirs(os.path.dirname(p), exist_ok=True) + with open(p, mode) as fh: fh.write(text) + return p + + +# ── scan ────────────────────────────────────────────────────────────────────────────────────────────────────────── +def scan_checks(): + import ax_nonsource + work = tempfile.mkdtemp(prefix='axiomcode-scan-') + try: + repo = os.path.join(work, 'repo'); cache = os.path.join(work, 'cache') + write(repo, 'src/app.ts', 'export function priceOf() {}\n') + write(repo, 'config/app.yml', 'handler: priceOf\nother: x.priceOf\nprefix: priceOf.rate\nnot: priceOfAll\n') + write(repo, 'mappers/order.xml', '\n') + write(repo, 'scripts/run.sh', 'echo $priceOf-x\necho priceOf$\necho ünïcode priceOf\n') + write(repo, 'blob.bin', b'\0\0priceOf\0', 'wb') + write(repo, 'big.json', '"priceOf" ' * 60000) + write(repo, 'notes.md', 'priceOf in prose\n') + old = time.time() - 60 + for d, _, fs in os.walk(repo): + for f in fs: os.utime(os.path.join(d, f), (old, old)) + indexed = {'src/app.ts'} + skip_dir = {'.git', 'node_modules', '.axiomcode'}; skip_ext = {'.ts', '.md'} + queries = [['priceOf'], ['Order.priceOf'], ['priceOf.rate'], ['rate'], ['x.priceOf'], ['priceOf-x'], ['priceOf$'], + ['ünïcode'], ['handler', 'priceOf'], ['a b c'], ['nothing-here']] + + def full(names): + os.environ['AXIOMCODE_NO_SCAN_CACHE'] = '1' + try: return ax_nonsource.NonSource(repo, cache, indexed, skip_dir, skip_ext).hits(names), \ + ax_nonsource.NonSource(repo, cache, indexed, skip_dir, skip_ext).files() + finally: del os.environ['AXIOMCODE_NO_SCAN_CACHE'] + + def cached(names): + ns = ax_nonsource.NonSource(repo, cache, indexed, skip_dir, skip_ext) + return ns.hits(names), ns.files() + + def agree(stage): + bad = [(q, full(q), cached(q)) for q in queries if full(q) != cached(q)] + check(f"scan: the cached scan finds what the full scan finds ({stage})", not bad, bad[:2]) + + agree('cold cache'); agree('warm cache') + # an edit that keeps the size, a new file, a deleted one, a file turned binary + p = os.path.join(repo, 'config/app.yml'); t = open(p).read().replace('handler: priceOf', 'handler: priceIf') + open(p, 'w').write(t); os.utime(p, (old + 10, old + 10)) + q = write(repo, 'config/new.properties', 'price.handler=priceOf\n'); os.utime(q, (old, old)) + os.remove(os.path.join(repo, 'scripts/run.sh')) + b = write(repo, 'mappers/order.xml', b'<\0binary priceOf', 'wb'); os.utime(b, (old + 10, old + 10)) + agree('after edits, an addition, a deletion and a file turned binary') + # a file written just now is not trusted to the cache: its mtime may not have ticked yet + write(repo, 'config/fresh.yml', 'fresh: priceOf\n') + agree('a file modified this second') + write(repo, 'config/fresh.yml', 'fresh: priceOf and more text\n') + agree('the same file modified again within the second') + + # a warm query opens only files whose words include the name + for i in range(200): write(repo, f'data/f{i}.json', f'{{"k{i}": "v{i}"}}\n') + for d, _, fs in os.walk(repo): + for f in fs: os.utime(os.path.join(d, f), (old, old)) + cached(['priceOf']) + opened = []; real = builtins.open + def spy(f, *a, **k): + if isinstance(f, str) and f.startswith(repo): opened.append(f) + return real(f, *a, **k) + builtins.open = spy + try: hits, _ = cached(['priceOf']) + finally: builtins.open = real + check("scan: a warm query opens only the files whose words include the name (not 200 unrelated ones)", + 0 < len(opened) <= 3 and hits == full(['priceOf'])[0], opened) + finally: + shutil.rmtree(work, ignore_errors=True) + + +# ── walk ────────────────────────────────────────────────────────────────────────────────────────────────────────── +def walk_checks(): + import ax_fresh + work = tempfile.mkdtemp(prefix='axiomcode-walk-') + try: + for rel in ('src/a.ts', 'src/b.tsx', 'src/c.js', 'lib/d.py', 'svc/E.cs', 'svc/obj/F.cs', 'svc/obj/g.ts', 'bin/h.js', 'bin/I.cs', + 'packages/web/j.ts', 'packages/k.cs', 'node_modules/x/l.ts', 'dist/m.js', 'app/src/main/java/N.java', + 'app/src/main/resources/META-INF/services/p.Q', 'tool', 'Directory.Build.props', 'package.json', 'pyproject.toml', + 'app/build.gradle', 'deep/obj/bin/r.ts', 'deep/obj/bin/S.cs'): + write(work, rel, '#!/usr/bin/env python3\n' if rel == 'tool' else 'x\n') + langs = 'typescript,java,python,javascript,csharp' + 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) + for d, subdirs, files in os.walk(work): + subdirs[:] = [s for s in subdirs if s not in prune] + 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)): + out.add(os.path.join(d, f)) + return out + for lang in (langs, 'csharp', 'typescript,csharp', 'java', 'python,javascript'): + got = list(ax_fresh.watched(work, lang)) + check(f"walk: one walk yields what one walk per language did ({lang})", set(got) == per_language(lang) and len(got) == len(set(got)), + sorted(set(got) ^ per_language(lang))) + finally: + shutil.rmtree(work, ignore_errors=True) + + +# ── verbs ───────────────────────────────────────────────────────────────────────────────────────────────────────── +def verbs_checks(): + front = subprocess.run(['bash', os.path.join(SCRIPTS, 'axiomcode'), '--verbs'], capture_output=True, text=True).stdout.split() + r = subprocess.run(['bash', os.path.join(ROOT, 'bin', 'axiomcode'), 'no-such-verb'], capture_output=True, text=True) + listed = next((l.split(':', 1)[1].split() for l in r.stderr.splitlines() if l.strip().startswith('ask:')), []) + check("verbs: bin/axiomcode knows exactly the verbs the frontend dispatches (less its `tests` alias)", + listed == [v for v in front if v != 'tests'], (listed, front)) + work = tempfile.mkdtemp(prefix='axiomcode-verbs-') + try: + log = os.path.join(work, 'spawned') + real_bash = shutil.which('bash') + shim = os.path.join(work, 'shim'); os.makedirs(shim) + for name in ('bash', 'sed', 'grep', 'tr', 'dirname'): + real = shutil.which(name) + if not real: continue + write(shim, name, f'#!{real_bash}\necho "{name} $*" >> "{log}"\nexec "{real}" "$@"\n'); os.chmod(os.path.join(shim, name), 0o755) + env = dict(os.environ, PATH=shim + os.pathsep + os.environ['PATH']) + subprocess.run([real_bash, os.path.join(ROOT, 'bin', 'axiomcode'), 'impact', '--help'], capture_output=True, text=True, env=env) + spawned = open(log).read().splitlines() if os.path.exists(log) else [] + before_verb = [] + for l in spawned: + if l.startswith('bash ') and l.split()[1].endswith(os.path.join('scripts', 'axiomcode')) and '--verbs' not in l: break + before_verb.append(l) + check("verbs: `axiomcode impact` reaches the frontend without starting a program to recognise the verb or find its root", + not before_verb, before_verb) + finally: + shutil.rmtree(work, ignore_errors=True) + + +# ── python ──────────────────────────────────────────────────────────────────────────────────────────────────────── +PY_JS = r''' +const cp = require('child_process'); let n = 0; const real = cp.spawnSync; +cp.spawnSync = (...a) => { n++; return real(...a); }; +const f = require(process.argv[1]); +const a = f.findPython(), k1 = n; const b = f.findPython(), k2 = n; +process.env.PATH = process.argv[2] + require('path').delimiter + process.env.PATH; +const c = f.findPython(), k3 = n; +console.log(JSON.stringify({a, b, c, first: k1, second: k2 - k1, moved: k3 - k2})); +''' + + +def python_checks(): + if not shutil.which('node'): print('skip python: no node'); return + work = tempfile.mkdtemp(prefix='axiomcode-py-') + try: + shadow = os.path.join(work, 'bin'); os.makedirs(shadow) + py = shutil.which('python3') + write(shadow, 'python3', f'#!/bin/sh\nexec "{py}" "$@"\n'); os.chmod(os.path.join(shadow, 'python3'), 0o755) + env = dict(os.environ, TMPDIR=work, TEMP=work, TMP=work); env.pop('AXIOMCODE_NO_PYTHON_CACHE', None); env.pop('AXIOMCODE_PYTHON', None) + r = subprocess.run(['node', '-e', PY_JS, os.path.join(ROOT, 'plugins', 'axiomcode', 'mcp', 'find-python.js'), shadow], + capture_output=True, text=True, env=env) + try: o = json.loads(r.stdout) + except ValueError: check('python: the probe runs', False, r.stdout + r.stderr); return + check("python: the probe runs once, and a second launch reuses its answer without starting Python", + o['first'] >= 1 and o['second'] == 0 and o['a'] == o['b'], o) + check("python: a python3 that now resolves elsewhere on PATH is probed again", o['moved'] >= 1 and o['c'].get('exe'), o) + finally: + shutil.rmtree(work, ignore_errors=True) + + +# ── the checks that need a graph ───────────────────────────────────────────────────────────────────────────────── +PROJECT = { + 'package.json': '{"name": "shop", "version": "1.0.0"}\n', + 'tsconfig.json': '{"compilerOptions": {"strict": true}}\n', + 'src/price.ts': 'export function priceOf(n: number): number {\n return n * 2;\n}\n\nexport function taxOf(n: number): number {\n return n / 10;\n}\n', + 'src/cart.ts': 'import { priceOf, taxOf } from "./price";\n\nexport function total(items: number[]): number {\n return items.map(priceOf).reduce((a, b) => a + b + taxOf(b), 0);\n}\n\nexport function first(items: number[]): number {\n return priceOf(items[0]);\n}\n', + 'src/cart.test.ts': 'import { total } from "./cart";\n\ntest("total", () => {\n expect(total([1, 2])).toBe(6);\n});\n', + 'config/app.yml': 'pricing:\n handler: priceOf\n', + '.next/server/app/page.js.map': '{"version":3,"sourcesContent":["export function priceOf(n) { return n * 2 }"],"names":["priceOf"]}\n', + 'coverage/lcov-report/price.ts.html': 'priceOf\n', +} + + +def git_repo(repo): + subprocess.run(['git', 'init', '-q', '.'], cwd=repo, check=True) + subprocess.run(['git', 'add', '-A'], cwd=repo, check=True) + subprocess.run(['git', '-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'x'], cwd=repo, check=True) + + +def graph_checks(): + work = tempfile.mkdtemp(prefix='axiomcode-latency-') + try: + repo = os.path.join(work, 'repo') + for rel, text in PROJECT.items(): write(repo, rel, text) + git_repo(repo) + r = subprocess.run(['bash', os.path.join(SCRIPTS, 'axiomcode-build'), repo], capture_output=True, text=True, timeout=900) + if r.returncode != 0: check('index the project', False, r.stdout + r.stderr); return + env = dict(os.environ, AXIOMCODE_NO_REFRESH='1') + def impact(*args, **extra): + p = subprocess.run([sys.executable, os.path.join(SCRIPTS, 'axiomcode-impact'), *args, repo], cwd=repo, + capture_output=True, text=True, env=dict(env, **extra), timeout=300) + return p.stdout, p.stderr, p.returncode + + out, _, _ = impact('priceOf') + check("skip: a name in a config file is still reported as bound from outside the source", 'config/app.yml' in out, out) + check("skip: a name in a framework build folder or a coverage report is not", '.next/' not in out and 'coverage/' not in out, out) + + # the answer is the same with every cache off, cold and warm; a repeat reads the solve; another question does not + solve = os.path.join(repo, '.axiomcode', 'out', 'dl', 'impact', 'solve'); shutil.rmtree(solve, ignore_errors=True) + ref = {t: impact(t, AXIOMCODE_NO_SOLVE_CACHE='1', AXIOMCODE_NO_SCAN_CACHE='1')[0] for t in ('priceOf', 'taxOf', 'total')} + a1, e1, _ = impact('priceOf', AXIOMCODE_PROFILE='1') + a2, e2, _ = impact('priceOf', AXIOMCODE_PROFILE='1') + b1, e3, _ = impact('taxOf', AXIOMCODE_PROFILE='1') + c1, _, _ = impact('total', '--page', '1') + check("solve: impact answers the same with the caches as without them, cold and warm", a1 == a2 == ref['priceOf'] and b1 == ref['taxOf'] and c1 == ref['total'], + (a1[-400:], ref['priceOf'][-400:])) + check("solve: the first query runs the rules and a repeat reads what they derived", 'souffle returned' in e1 and 'solve read from the cache' in e2, e1 + e2) + check("solve: another target on the same graph runs the rules, not the cached answer", 'souffle returned' in e3, e3) + # a changed non-source file is a changed per-query fact: the solve runs again and sees it + write(repo, 'config/app.yml', 'pricing:\n handler: priceOf\n fallback: priceOf\n') + a3, e4, _ = impact('priceOf', AXIOMCODE_PROFILE='1') + check("solve: a new mention in a config file is a new question, not a cache hit", 'souffle returned' in e4 and a3 == impact('priceOf', AXIOMCODE_NO_SOLVE_CACHE='1', AXIOMCODE_NO_SCAN_CACHE='1')[0], e4) + + # build: no graph, under the MCP server + fresh = os.path.join(work, 'fresh'); shutil.copytree(repo, fresh, ignore=shutil.ignore_patterns('.axiomcode')) + disp = os.path.join(SCRIPTS, 'axiomcode') + benv = dict(os.environ, AXIOMCODE_BUILD_NOWAIT='1', AXIOMCODE_BUILD_WAIT='0') + t = time.time(); p = subprocess.run(['bash', disp, 'impact', 'priceOf', fresh], capture_output=True, text=True, env=benv, timeout=120); t1 = time.time() - t + check("build: a query with no graph answers at once that the graph is being built, and at what stage", + t1 < 20 and 'is being built' in p.stdout and 'ask again' in p.stdout, (round(t1, 1), p.stdout + p.stderr)) + t = time.time(); p2 = subprocess.run(['bash', disp, 'path', 'total', 'priceOf', fresh], capture_output=True, text=True, env=benv, timeout=120); t2 = time.time() - t + import ax_fresh + building = ax_fresh.building(fresh) + check("build: a second query while it builds says so too, instead of queueing a second build behind it", + t2 < 20 and ('is being built' in p2.stdout or (not building and 'priceOf' in p2.stdout)), (round(t2, 1), p2.stdout + p2.stderr)) + end = time.time() + 900 + while (ax_fresh.building(fresh) or not os.path.exists(os.path.join(fresh, '.axiomcode', 'out', 'graph.sqlite'))) and time.time() < end: time.sleep(1) + p3 = subprocess.run(['bash', disp, 'impact', 'priceOf', fresh], capture_output=True, text=True, env=benv, timeout=300) + check("build: once it is done the same call answers", 'change: priceOf' in p3.stdout and 'first' in p3.stdout and 'is being built' not in p3.stdout, p3.stdout + p3.stderr) + finally: + shutil.rmtree(work, ignore_errors=True) + + +# ── timeout ─────────────────────────────────────────────────────────────────────────────────────────────────────── +def timeout_checks(): + spec = importlib.util.spec_from_file_location('axserver', 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) + work = tempfile.mkdtemp(prefix='axiomcode-timeout-') + try: + try: out = m.run(['context', 'anything', work], timeout=0.01); raised = '' + except Exception as e: out, raised = '', repr(e) + check("timeout: an MCP call past the server's timeout answers in words, not with an exception", + not raised and 'did not answer within' in out, raised or out) + finally: + shutil.rmtree(work, ignore_errors=True) + + +def main(): + scan_checks(); walk_checks(); verbs_checks(); python_checks(); timeout_checks() + if '--no-engine' not in sys.argv: graph_checks() + bad = RESULTS.count(False) + print(f"{len(RESULTS)} check(s) held" if not bad else f"{bad} of {len(RESULTS)} FAILED") + return 1 if bad else 0 + + +if __name__ == '__main__': + sys.exit(main())