From c0ee951d266e9b2ed27cee4ec06c3d3023589de9 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:08:09 -0700 Subject: [PATCH] plugin: a corrupt graph.sqlite is said, moved aside and rebuilt, never a traceback A graph.sqlite that is not a readable database (a disk that filled, a copy cut short) made every verb, from the shell and through the MCP server, die in a Python sqlite3 traceback. Each verb's door (axiomcode-path G, axiomcode-graph) now probes the graph (schema read, quick_check only when that fails or when an sqlite error escapes mid-answer), says "graph is corrupt", moves it aside with a marker that stops the build calling unchanged files up to date, rebuilds it, and answers from the last good (baseline) graph meanwhile. Another language's corrupt graph is named and left out; the main language still answers. tests/corrupt_graph.py: garbage, truncated, bad page, marker, MCP and other-language cases, with controls (sound graph, empty graph, up to date). Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> Co-Authored-By: Claude --- .../skills/axiomcode/scripts/ax_contract.py | 91 ++++++++- .../skills/axiomcode/scripts/ax_fresh.py | 51 +++++ .../skills/axiomcode/scripts/ax_langs.py | 6 +- .../skills/axiomcode/scripts/axiomcode-build | 8 +- .../skills/axiomcode/scripts/axiomcode-graph | 12 +- .../skills/axiomcode/scripts/axiomcode-path | 3 + tests/corrupt_graph.py | 182 ++++++++++++++++++ 7 files changed, 345 insertions(+), 8 deletions(-) create mode 100644 tests/corrupt_graph.py diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_contract.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_contract.py index f3efbd49f..3f6400fe0 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_contract.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_contract.py @@ -479,7 +479,8 @@ def ensure_graph(repo, db): # relink above took every pointer that leaves this .axiomcode/out: one still here is this repository's own try: gone = f" (.axiomcode/out/graph.sqlite points at {os.readlink(db)}, which is not there)" except OSError: gone = '' - print(f"the graph of {repo} is missing (a build was interrupted){gone} — rebuilding it; the baseline edits are measured against is kept …", file=sys.stderr) + why = "it was corrupt and was moved aside" if os.path.exists(os.path.join(ax_fresh.out_dir(rr), 'corrupt')) else "a build was interrupted" + print(f"the graph of {repo} is missing ({why}){gone} — rebuilding it; the baseline edits are measured against is kept …", file=sys.stderr) 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) @@ -534,6 +535,94 @@ def _start_build(argv, log, env): return subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=log, stderr=log, close_fds=True, env=env) +def usable_graph(repo, gdir): + """The graph directory a verb reads: `gdir` itself, unless its graph.sqlite is CORRUPT (a disk that filled, a copy cut + short). Then it is never read — every verb died in a Python traceback on it — but moved aside and rebuilt: the main + graph through ensure_graph (a shell waits for it; the MCP server starts it and answers at once), another language's + in the background. Until the rebuild is in, the last good graph answers (the baseline a refresh kept), and says so + on a `graph refresh:` line, which the MCP server carries into the answer. With none, the verb stops in words.""" + db = os.path.join(gdir, 'out', 'graph.sqlite') + if not os.path.exists(db): return gdir + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))); import ax_fresh + why = ax_fresh.graph_corrupt(db) + if not why: return gdir + return _corrupt(repo, gdir, db, why) + + +def _corrupt(repo, gdir, db, why): + import ax_fresh + rr = os.path.realpath(repo) + lang = ax_fresh.graph_lang() + # a graph someone placed with AXIOMCODE_GRAPH is theirs: said, never moved or rebuilt. One of this repository's own + # language graphs (the dispatcher sets AXIOMCODE_GRAPH to it, with AXIOMCODE_GRAPH_LANG) is ours to repair + placed = os.environ.get('AXIOMCODE_GRAPH') + own = not placed or (lang and os.path.realpath(placed) == os.path.realpath(os.path.join(rr, '.axiomcode', 'lang', lang))) + what = f"the {lang} graph" if lang else "the graph" + if not own: + _stop(f"{what} at {db} is corrupt ({why}); AXIOMCODE_GRAPH placed it, so it is not rebuilt here — rebuild it where it was built") + moved = ax_fresh.quarantine(rr, db, why) + print(f"graph refresh: {what} at {db} is corrupt ({why}) — " + (f"moved aside to {moved} and rebuilding it" if moved else "rebuilding it"), + file=sys.stderr, flush=True) + if not lang: + if ensure_graph(repo, db) and os.path.exists(db) and not ax_fresh.graph_corrupt(db): return gdir + elif not ax_fresh.building(rr): + _rebuild_in_background(rr) + good = ax_fresh.last_good_graph(rr) + if good: + print(f"graph refresh: until the rebuild is in, this answer comes from the last good graph ({good}), which predates " + f"the edits since it was built", file=sys.stderr, flush=True) + return good + _stop(_BUILD_NOTE[-1] if _BUILD_NOTE else + f"{what} at {db} was corrupt and there is no earlier graph to answer from; a rebuild is " + f"{'running' if ax_fresh.building(rr) else 'needed'} — ask again when it is done, or run `axiomcode index {repo}`") + + +def _stop(msg): + """the verb's refusal, as the verbs' own die() gives it: the words on stdout, exit 2""" + print(msg); sys.exit(2) + + +def _rebuild_in_background(repo): + """start a detached build of the repository, keeping the baseline, as a repair does (another language's graph was + corrupt; the build rebuilds every language, and the corrupt marker keeps it from calling the tree up to date)""" + build = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'axiomcode-build') + if not os.path.exists(build): return + env = dict(os.environ, AXIOMCODE_KEEP_BASE='1', AXIOMCODE_BACKGROUND='1', AXIOMCODE_REFRESH_REASON='a corrupt graph') + for k in ('AXIOMCODE_GRAPH', 'AXIOMCODE_GRAPH_LANG', 'AXIOMCODE_FANOUT', 'AXIOMCODE_LANG'): env.pop(k, None) + try: + log = open(os.path.join(repo, '.axiomcode', 'refresh.log'), 'a') + kw = dict(start_new_session=True) if os.name != 'nt' else dict(creationflags=0x00000008 | 0x00000200) + subprocess.Popen([os.environ.get('AXIOMCODE_BASH') or 'bash', build, repo], stdin=subprocess.DEVNULL, stdout=log, stderr=log, + close_fds=True, env=env, **kw) + except OSError: pass + + +def on_corrupt(repo, db): + """A graph that reads cleanly when opened can still hold a malformed page that only a query reaches. Installed by a + verb once its graph is open: an sqlite error that escapes the verb is checked with quick_check, and when the graph is + corrupt the verb says so and moves it aside for a rebuild instead of printing a traceback. Any other error is + reported as it was.""" + prev = sys.excepthook + def hook(t, e, tb): + import sqlite3 + if isinstance(e, sqlite3.DatabaseError) and os.path.exists(db): + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))); import ax_fresh + why = ax_fresh.graph_corrupt(db, thorough=True) + if why: + rr = os.path.realpath(repo); lang = ax_fresh.graph_lang() + placed = os.environ.get('AXIOMCODE_GRAPH') + own = not placed or (lang and os.path.realpath(placed) == os.path.realpath(os.path.join(rr, '.axiomcode', 'lang', lang))) + moved = ax_fresh.quarantine(rr, db, why) if own else '' + if own and not ax_fresh.building(rr): _rebuild_in_background(rr) + print(f"{'the ' + lang + ' graph' if lang else 'the graph'} at {db} is corrupt ({why}) — " + + (f"moved aside to {moved}; it is being rebuilt, ask again in a minute" if moved else + "rebuild it where it was built" if not own else f"run `axiomcode index {repo}` to rebuild it"), + file=sys.stderr, flush=True) + sys.stdout.flush(); os._exit(1) # SystemExit raised in an excepthook is itself reported + prev(t, e, tb) + sys.excepthook = hook + + def _published(repo): """the main graph is out while the build goes on solving the repository's other languages: the answer is given now, and names on stderr (a `graph refresh:` line, which the MCP server carries into the answer) what it cannot see yet""" diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py index a46d39e45..33abf6379 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py @@ -438,6 +438,57 @@ def relink(repo, locked=False, say=True): if say: print(msg, file=sys.stderr) return msg +def graph_corrupt(db, thorough=False): + """why the graph at db cannot be read ('' when it can). A disk that filled mid-write, a copy cut short or a crash can + leave graph.sqlite malformed, and every verb then died in a Python traceback (`file is not a database`, `database disk + image is malformed`) instead of saying what was wrong. The probe is cheap (the schema is read, as every verb reads it + first); only when it fails, or with `thorough` (a verb that hit an error mid-answer), is quick_check asked, since on a + large graph it reads every page. An empty file is not corrupt: it is a graph that was never indexed.""" + import sqlite3 + try: + con = sqlite3.connect(f"file:{db}?mode=ro", uri=True) + except sqlite3.Error as e: return str(e) + try: + try: + con.execute("SELECT count(*) FROM sqlite_master").fetchone() + if not thorough: return '' + except sqlite3.OperationalError as e: + if 'locked' in str(e) or 'unable to open' in str(e): return '' # busy or gone, not malformed + first = str(e) + except sqlite3.DatabaseError as e: first = str(e) + else: first = '' + try: + bad = [r[0] for r in con.execute("PRAGMA quick_check(3)").fetchall() if r[0] != 'ok'] + except sqlite3.DatabaseError as e: return first or str(e) + return first or '; '.join(bad) + finally: con.close() + +def quarantine(repo, db, why): + """move a corrupt graph out of the way so the next build replaces it rather than reads it, and mark the repository so + that build cannot take the unchanged files for an up-to-date graph (axiomcode-build reads .axiomcode/out/corrupt). A + pointer's TARGET is moved and the pointer left dangling, which every verb already treats as a graph to repair (a + rebuild that keeps the baseline). Returns where the corrupt file went, or '' when it could not be moved.""" + real = os.path.realpath(db); dst = real + '.corrupt' + try: + os.replace(real, dst) + for ext in ('-wal', '-shm', '-journal'): + if os.path.exists(real + ext): os.replace(real + ext, dst + ext) + except OSError: dst = '' + try: + os.makedirs(out_dir(repo), exist_ok=True) + with open(os.path.join(out_dir(repo), 'corrupt'), 'w') as f: f.write(f"{db}: {why}\n") + except OSError: pass + return dst + +def last_good_graph(repo): + """a graph directory that can still answer when the current graph is corrupt: the baseline graph a refresh kept + (.axiomcode/base, or base/lang/ for another language), when it reads cleanly. It describes an earlier tree, so + an answer from it must say so. None when there is none.""" + root = os.path.join(repo, '.axiomcode', 'base') + d = os.path.join(root, 'lang', graph_lang()) if graph_lang() else root + db = os.path.join(d, 'out', 'graph.sqlite') + return d if os.path.isfile(db) and os.path.getsize(db) and not graph_corrupt(db) else None + def graph_lang(): """the language whose graph a verb reads when it is not the main one (AXIOMCODE_GRAPH_LANG, set by the dispatcher as it asks each language's graph in turn), else ''""" diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_langs.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_langs.py index 436a7873a..7db30c957 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_langs.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_langs.py @@ -92,6 +92,9 @@ def main(argv): named, outside = by_scope(named) named, others = by_landing(named) answered = [n for n in named if n[2] == 0] + # a graph that could not answer at all keeps its `graph refresh:` lines (a corrupt graph moved aside and being rebuilt, + # ax_contract.usable_graph): dropped with its refusal, the answer read as whole while a language was missing from it + notes = ''.join(l + '\n' for n in named if n[2] != 0 and answered for l in n[4].splitlines() if l.startswith('graph refresh:')) if not answered: # a graph that does not hold the --in scope says only that; the graph that holds it has the refusal that # matters (nothing under it matches, or the name is not there), so a scope in one language is judged by it @@ -110,7 +113,7 @@ def main(argv): print(json.dumps(base, indent=1)) else: print(json.dumps(base, indent=1) if not isinstance(base, str) else base, end='' if isinstance(base, str) else '\n') - sys.stderr.write(''.join(n[4] for n in answered)) + sys.stderr.write(''.join(n[4] for n in answered) + notes) return 0 show = answered or named @@ -121,6 +124,7 @@ def main(argv): if len(show) > 1 or not is_main: print(('' if i == 0 else '\n') + f"══ {lang} graph ══" + ('' if is_main else f" (.axiomcode/lang/{lang})"), flush=True) sys.stdout.write(out); sys.stdout.flush(); sys.stderr.write(err); sys.stderr.flush() + if notes: sys.stderr.write(notes); sys.stderr.flush() if answered and others: print(f"\nthe --from name is also declared in the {', '.join(others)} graph(s), where its flow reaches " "half as many of the task's words or fewer; --lang asks one of them") diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build index 83049cf50..75938dbc4 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build @@ -244,13 +244,16 @@ LANGDIR="$REPO/.axiomcode/lang" # one graph directory per language other th # file the parser reads with its hash; when none differs the graph is current whatever git says: a dirty tree whose # edits are already indexed is not rebuilt, and a tree edited and restored, which git calls clean against a graph built # from the edit, is. A commit that changed no file moves the stamp, not the graph. -if [ -f "$OUT/graph.sqlite" ] && [ -f "$OUT/stamp" ] && [ ! -f "$OUT/partial" ] && AXIOMCODE_ENGINE="$ENGINE" python3 "$H/ax_fresh.py" uptodate "$REPO" "$LANGS" "${AXIOMCODE_SRC:-}" "${STAMP#"$PREFIX"}"; then +# A GRAPH A QUERY FOUND CORRUPT (ax_fresh.quarantine leaves .axiomcode/out/corrupt) is rebuilt even though no file moved: +# the files still match the table, so without this the build said "up to date" over a graph that could not be read. +if [ -f "$OUT/corrupt" ]; then echo "rebuilding: $(head -1 "$OUT/corrupt")"; fi +if [ ! -f "$OUT/corrupt" ] && [ -f "$OUT/graph.sqlite" ] && [ -f "$OUT/stamp" ] && [ ! -f "$OUT/partial" ] && AXIOMCODE_ENGINE="$ENGINE" python3 "$H/ax_fresh.py" uptodate "$REPO" "$LANGS" "${AXIOMCODE_SRC:-}" "${STAMP#"$PREFIX"}"; then if [ "$(cat "$OUT/stamp")" != "$STAMP" ]; then echo "$STAMP" > "$OUT/stamp"; INDEXED_TREE="$OLD_INDEXED"; set_base "$OLD_INDEXED"; PREV=""; keep_base_graph; commit_base; fi has_symbols "$OUT/graph.sqlite" || python3 "$H/axiomcode-index" "$REPO" [ -f "$REPO/.axiomcode/engine" ] || printf '%s\n' "$ENGINE" > "$REPO/.axiomcode/engine" || true echo "graph up to date (${LANGS//,/, }) at $OUT/graph.sqlite"; exit 0 fi -if [ ! -f "$OUT/files.json" ] && [ -f "$OUT/stamp" ] && [ "$(cat "$OUT/stamp")" = "$STAMP" ] && [ -f "$OUT/graph.sqlite" ] && [ -z "$(cd "$REPO" && git status --porcelain 2>/dev/null | head -1)" ]; then +if [ ! -f "$OUT/corrupt" ] && [ ! -f "$OUT/files.json" ] && [ -f "$OUT/stamp" ] && [ "$(cat "$OUT/stamp")" = "$STAMP" ] && [ -f "$OUT/graph.sqlite" ] && [ -z "$(cd "$REPO" && git status --porcelain 2>/dev/null | head -1)" ]; then has_symbols "$OUT/graph.sqlite" || python3 "$H/axiomcode-index" "$REPO" python3 "$H/axiomcode-impact" --warm "$REPO" || true # export the graph's facts now (~9 s on 1,373 files): the first # query pays it otherwise, and for the plugin that first query is inside hooks/changes.py's timeout=14, several at once. @@ -418,6 +421,7 @@ publish_main(){ done commit_base rm -rf "$PREV" + rm -f "$OUT/corrupt" "$OUT/$LANG_ARG/graph.sqlite.corrupt"* "$LANGDIR"/*/out/graph.sqlite.corrupt* # a sound graph replaced what was quarantined MAIN_DONE=1; PUBLISHING="" # the languages still unsolved are named in $OUT/partial: the graph is not up to date while it is there, so the next # build (a refresh the next query starts, or an index) builds them rather than calling the file table current diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-graph b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-graph index 478b64f18..b117bc73d 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-graph +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-graph @@ -31,7 +31,7 @@ the caller, never a drawn edge. import os, re, sys, json, time, sqlite3, collections, subprocess HERE = os.path.dirname(os.path.abspath(__file__)) -sys.path.insert(0, HERE); import ax_contract +sys.path.insert(0, HERE); import ax_contract, ax_fresh TESTRE = re.compile(r'(^|/)(tests?|testing|__tests__|test-data|fixtures?)(/|$)|(^|/)test_|conftest\.py$|/src/test/|Tests?\.java$|\.(spec|test)\.[cm]?[tj]sx?$') METHOD_KINDS = ('function', 'method', 'constructor', 'module') TYPE_KINDS = ('class', 'interface', 'enum', 'type', 'namespace') @@ -51,13 +51,17 @@ def graph_dbs(repo, gdir): ld = os.path.join(repo, '.axiomcode', 'lang') for l in sorted(os.listdir(ld)) if os.path.isdir(ld) else (): d = os.path.join(ld, l, 'out', 'graph.sqlite') - if os.path.exists(d): dbs.append(d) + if not os.path.exists(d): continue + why = ax_fresh.graph_corrupt(d) + # another language's corrupt graph is left out of the page, said, rather than ending the page in a traceback + if why: print(f"graph refresh: the {l} graph at {d} is corrupt ({why}) and is left out of this page; `axiomcode index` rebuilds it", file=sys.stderr) + else: dbs.append(d) return dbs def export(repo, page=None, how=''): repo = os.path.realpath(repo or '.') - gdir = os.environ.get('AXIOMCODE_GRAPH') or os.path.join(repo, '.axiomcode') + gdir = ax_contract.usable_graph(repo, os.environ.get('AXIOMCODE_GRAPH') or os.path.join(repo, '.axiomcode')) db = os.path.join(gdir, 'out', 'graph.sqlite') # the same door every other verb goes through: built on demand under the CLI, refused in words # that name the one command that helps under anything else. @@ -76,7 +80,7 @@ def export(repo, page=None, how=''): if r['line'] and len(sites[c]) < 400: sites[c].append([r['line'], r['written'] or '', target, r['tier']]) skipped, tiers = None, collections.Counter() for db in graph_dbs(repo, gdir): - con = sqlite3.connect(db); con.row_factory = sqlite3.Row + con = sqlite3.connect(db); con.row_factory = sqlite3.Row; ax_contract.on_corrupt(repo, db) q = lambda sql: con.execute(sql).fetchall() has = lambda t: bool(con.execute("SELECT 1 FROM sqlite_master WHERE name=?", (t,)).fetchone()) run = {r['key']: r['value'] for r in q("SELECT key, value FROM run")}; runs.append(run) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index 571319c8a..8a6d28aa1 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -116,6 +116,8 @@ class G: self.repo = os.path.realpath(repo or '.') os.environ.setdefault('AXIOMCODE_REPO', self.repo) # which repository's recorded engine dl_program looks in gdir = os.environ.get('AXIOMCODE_GRAPH') or os.path.join(self.repo, '.axiomcode') + # a CORRUPT graph is moved aside and rebuilt, and the last good graph answers meanwhile (ax_contract.usable_graph) + gdir = ax_contract.usable_graph(self.repo, gdir) self.db = os.path.join(gdir, 'out', 'graph.sqlite') # ONE door for every verb. impact, changed, context and test-impact all load this file as a # module and construct this class, so a repository with no graph is handled here, once, for @@ -125,6 +127,7 @@ class G: if not os.path.exists(self.db) and not ax_contract.ensure_graph(self.repo, self.db): die(ax_contract.no_graph(self.repo, self.db)) self.connect() + ax_contract.on_corrupt(self.repo, self.db) if not self.has('symbols'): # a graph that was built but never indexed: the index is this tool's own, and adding it # is seconds, so asking the caller to run one more command buys nothing. diff --git a/tests/corrupt_graph.py b/tests/corrupt_graph.py new file mode 100644 index 000000000..b999e00d1 --- /dev/null +++ b/tests/corrupt_graph.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +"""tests/corrupt_graph.py — a graph.sqlite that is not a readable database is said, moved aside and rebuilt, never read. + +A disk that filled mid-write, a copy cut short or a crash leaves graph.sqlite malformed, and every verb, from the shell +and through the MCP server, died in a Python traceback (`sqlite3.DatabaseError: file is not a database`, `database disk +image is malformed`). Each check lays out a throwaway git repository with a real graph (so it needs the engine, as +tests/refresh_races.py), damages the graph one way, and asks what a user would ask next. + + garbage graph.sqlite is random bytes: `impact` says the graph is corrupt, rebuilds it and answers; the + rebuilt graph reads cleanly and the marker is gone. No traceback. + truncated graph.sqlite cut short, asked without a build (as the hooks ask): the last good graph (the kept + baseline) answers and the answer says so. With no such graph the verb stops in words, exit 2. + bad page the schema reads but a page a query needs does not: the error mid-answer is checked with + quick_check, said in words, and the graph moved aside for a rebuild. No traceback. + up to date a build over unchanged files with the corrupt marker rebuilds instead of saying "up to date". + other language a corrupt graph of another language (.axiomcode/lang/) is said, and the main language + still answers. +Controls: a sound graph answers exactly as before with no word of corruption and no marker; an EMPTY graph.sqlite is +not called corrupt (it is a graph never indexed); a build over unchanged files without the marker is still "up to date". + + python3 tests/corrupt_graph.py [-v] +""" +import json, os, shutil, subprocess, sys, tempfile, time + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +AX = os.path.join(ROOT, 'bin', 'axiomcode') +SCRIPTS = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts') +BUILD = os.path.join(SCRIPTS, 'axiomcode-build') +IMPACT = os.path.join(SCRIPTS, 'axiomcode-impact') + +UTIL = ('export function helper(): number {\n return 1\n}\n\n' + 'export function caller(): number {\n return helper()\n}\n\n' + 'export function other(): number {\n return helper()\n}\n') +TSCONFIG = '{ "compilerOptions": { "strict": true }, "include": ["src"] }\n' + + +def sh(cwd, *cmd, env=None, timeout=900): + return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, env=env, timeout=timeout) + + +def repo_with(work, name, files): + repo = os.path.join(work, name) + for rel, text in files.items(): + os.makedirs(os.path.dirname(os.path.join(repo, rel)), exist_ok=True) + open(os.path.join(repo, rel), 'w').write(text) + sh(repo, 'git', 'init', '-q'); sh(repo, 'git', 'add', '-A') + sh(repo, 'git', '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'base') + return repo + + +def tiers(db): + import sqlite3 + c = sqlite3.connect(f'file:{db}?mode=ro', uri=True) + try: return dict(c.execute("SELECT tier, count(*) FROM call_edges GROUP BY tier").fetchall()) + finally: c.close() + + +def main(argv): + verbose = '-v' in argv + fails = [] + + def check(ok, why, detail=''): + print(('ok ' if ok else 'FAIL ') + why + ('' if ok or not (detail or '').strip() else '\n ' + detail.strip()[-1500:].replace('\n', '\n '))) + if not ok: fails.append(why) + + env = dict(os.environ, AXIOMCODE_ENGINE=ROOT, AXIOMCODE_NO_REFRESH='1') + hook = {k: v for k, v in env.items() if k not in ('AXIOMCODE_AUTOBUILD', 'AXIOMCODE_BUILD_NOWAIT')} # as the hooks ask + work = tempfile.mkdtemp(prefix='axiomcode-corrupt-') + try: + repo = repo_with(work, 'app', {'tsconfig.json': TSCONFIG, 'src/util.ts': UTIL}) + out = os.path.join(repo, '.axiomcode', 'out'); ptr = os.path.join(out, 'graph.sqlite') + b = sh(repo, AX, 'index', '.', env=env) + check(b.returncode == 0, 'the graph builds', b.stdout + b.stderr) + good = os.path.realpath(ptr); saved = os.path.join(work, 'good.sqlite'); shutil.copyfile(good, saved) + t0 = tiers(good) + + # ── control: a sound graph ────────────────────────────────────────────────────────────────────────── + r0 = sh(repo, AX, 'impact', 'helper', '.', env=env) + check(r0.returncode == 0 and 'caller' in r0.stdout, 'control: a sound graph answers', r0.stdout + r0.stderr) + check('corrupt' not in (r0.stdout + r0.stderr) and not os.path.exists(os.path.join(out, 'corrupt')), + 'control: a sound graph is never called corrupt', r0.stderr) + u = sh(repo, 'bash', BUILD, repo, env=env) + check('up to date' in u.stdout, 'control: a build over unchanged files without the marker is still "up to date"', u.stdout + u.stderr) + + # ── garbage ───────────────────────────────────────────────────────────────────────────────────────── + open(good, 'wb').write(os.urandom(8192)) + t = time.time(); r = sh(repo, AX, 'impact', 'helper', '.', env=env); took = time.time() - t + both = r.stdout + r.stderr + check('Traceback' not in both, 'garbage: no traceback', both) + check('is corrupt' in r.stderr, 'garbage: the verb says the graph is corrupt', both) + check(r.returncode == 0 and 'caller' in r.stdout, f'garbage: the graph is rebuilt and the verb answers ({took:.0f}s)', both) + check(os.path.exists(ptr) and tiers(os.path.realpath(ptr)) == t0, 'garbage: the rebuilt graph reads cleanly, with the same edges', str(t0)) + check(not os.path.exists(os.path.join(out, 'corrupt')), 'garbage: the corrupt marker goes with the rebuild') + + # ── truncated, with and without a last good graph ─────────────────────────────────────────────────── + good = os.path.realpath(ptr) + base = os.path.join(repo, '.axiomcode', 'base', 'out'); os.makedirs(base, exist_ok=True) + shutil.copyfile(saved, os.path.join(base, 'graph.sqlite')) # a baseline a refresh kept + data = open(good, 'rb').read(); open(good, 'wb').write(data[:len(data) // 3]) + r = sh(repo, 'python3', IMPACT, 'helper', repo, env=hook) + both = r.stdout + r.stderr + check('Traceback' not in both, 'truncated: no traceback', both) + check(r.returncode == 0 and 'caller' in r.stdout and 'last good graph' in r.stderr, + 'truncated: the last good graph answers, and the answer says so', both) + check(os.path.exists(os.path.join(out, 'corrupt')), 'truncated: the repository is marked for a rebuild') + shutil.rmtree(os.path.join(repo, '.axiomcode', 'base')) + shutil.copyfile(saved, good); open(good, 'wb').write(data[:len(data) // 3]) + r = sh(repo, 'python3', IMPACT, 'helper', repo, env=hook) + both = r.stdout + r.stderr + check('Traceback' not in both and r.returncode == 2 and 'corrupt' in both and 'axiomcode index' in both, + 'truncated: with no earlier graph the verb stops in words that name the fix', both) + + # ── up to date ────────────────────────────────────────────────────────────────────────────────────── + u = sh(repo, 'bash', BUILD, repo, env=env) + check(u.returncode == 0 and 'up to date' not in u.stdout and os.path.exists(ptr) and tiers(os.path.realpath(ptr)) == t0, + 'up to date: a build with the corrupt marker rebuilds instead of saying "up to date"', u.stdout + u.stderr) + check(not os.path.exists(os.path.join(out, 'corrupt')), 'up to date: the marker goes with the rebuild') + + # ── through the MCP server ────────────────────────────────────────────────────────────────────────── + # the server runs the verb without waiting for a build (AXIOMCODE_BUILD_NOWAIT) and hands back its words + open(os.path.realpath(ptr), 'wb').write(os.urandom(8192)) + sys.path.insert(0, os.path.join(ROOT, 'plugins', 'axiomcode', 'mcp')); os.environ.update(AXIOMCODE_ENGINE=ROOT, AXIOMCODE_NO_REFRESH='1') + import server + text = server.run(['impact', 'helper', repo]) + check('Traceback' not in text and 'corrupt' in text, 'mcp: the tool answer says the graph is corrupt, no traceback', text) + sys.path.insert(0, SCRIPTS); import ax_fresh + for _ in range(600): # the rebuild it started + if os.path.exists(ptr) and not ax_fresh.building(repo): break + time.sleep(1) + text = server.run(['impact', 'helper', repo]) + check('caller' in text and 'corrupt' not in text, 'mcp: once rebuilt the tool answers again', text) + + # ── bad page ──────────────────────────────────────────────────────────────────────────────────────── + good = os.path.realpath(ptr) + data = bytearray(open(good, 'rb').read()); page = int.from_bytes(data[16:18], 'big') or 65536 + for p in range(max(2, len(data) // page // 2), len(data) // page): # the later pages: tables, not the schema + data[p * page:p * page + 8] = b'\xff' * 8 + open(good, 'wb').write(bytes(data)) + import sqlite3 + try: + sqlite3.connect(f'file:{good}?mode=ro', uri=True).execute("SELECT count(*) FROM sqlite_master").fetchone(); probe = True + except sqlite3.DatabaseError: probe = False + r = sh(repo, 'python3', IMPACT, 'helper', repo, env=hook) + both = r.stdout + r.stderr + check('Traceback' not in both and 'corrupt' in both, f'bad page: the error {"mid-answer" if probe else "on open"} is said in words, not a traceback', both) + check(not os.path.exists(good) or os.path.exists(good + '.corrupt'), 'bad page: the graph is moved aside for a rebuild') + u = sh(repo, 'bash', BUILD, repo, env=env) + check(u.returncode == 0 and tiers(os.path.realpath(ptr)) == t0, 'bad page: the next build restores the graph', u.stdout + u.stderr) + + # ── control: an empty graph.sqlite is not corrupt ─────────────────────────────────────────────────── + sys.path.insert(0, SCRIPTS); import ax_fresh + empty = os.path.join(work, 'empty.sqlite'); open(empty, 'w').close() + check(ax_fresh.graph_corrupt(empty) == '', 'control: an empty graph.sqlite is not called corrupt (it was never indexed)') + check(ax_fresh.graph_corrupt(os.path.realpath(ptr)) == '' and ax_fresh.graph_corrupt(os.path.realpath(ptr), thorough=True) == '', + 'control: a sound graph passes the probe and quick_check') + + # ── other language ────────────────────────────────────────────────────────────────────────────────── + mixed = repo_with(work, 'mixed', {'tsconfig.json': TSCONFIG, 'src/util.ts': UTIL, + 'pkg/__init__.py': '', 'pkg/m.py': 'def h():\n return 1\n\ndef g():\n return h()\n'}) + b = sh(mixed, AX, 'index', '.', '--lang', 'typescript,python', env=env) + ldb = os.path.join(mixed, '.axiomcode', 'lang', 'python', 'out', 'graph.sqlite') + check(b.returncode == 0 and os.path.isfile(ldb), 'other language: both graphs build', b.stdout + b.stderr) + if os.path.isfile(ldb): + open(ldb, 'wb').write(os.urandom(8192)) + r = sh(mixed, AX, 'impact', 'helper', '.', env=env) + both = r.stdout + r.stderr + check('Traceback' not in both and 'caller' in r.stdout, 'other language: the main language still answers, no traceback', both) + check('python graph' in both and 'corrupt' in both, 'other language: the corrupt python graph is named', both) + for _ in range(600): # its background rebuild + if os.path.isfile(ldb) and not ax_fresh.graph_corrupt(ldb) and not ax_fresh.building(mixed): break + time.sleep(1) + r = sh(mixed, AX, 'impact', 'h', '.', env=env) + check(r.returncode == 0 and 'g' in r.stdout and 'corrupt' not in r.stderr, 'other language: its graph is rebuilt and answers again', r.stdout + r.stderr) + finally: + if verbose: print('work dir:', work) + else: shutil.rmtree(work, ignore_errors=True) + print(f"\n{'FAILED' if fails else 'passed'}: {len(fails)} failure(s)") + return 1 if fails else 0 + + +if __name__ == '__main__': + sys.exit(main(sys.argv[1:]))