diff --git a/README.md b/README.md index 166aaf3d..2ff85549 100644 --- a/README.md +++ b/README.md @@ -308,12 +308,12 @@ about one change had to read 95 of 43,793 methods, and every true direct caller ## CLI commands -Four questions, each answered as numbered places with the code of the function each one sits in. The MCP server -offers the same four as tools: `find(question)`, `impact(name)`, `path(start, end)` and `tests()`. +Three questions, each answered as numbered places with the code of the function each one sits in. The MCP server +offers the same three as tools: `impact(name)`, `path(start, end)` and `tests()`. Finding where code lives is +left to your own search: bring the name you found to these commands. | command | what it answers | |---|---| -| `axiomcode find ""` | where the code for a task lives, when you have it in words and not yet a name | | `axiomcode impact ` | who calls it, what a change to it reaches, and the tests that exercise it | | `axiomcode impact` | the same for the declarations your uncommitted edits changed; the answer starts with `your edits:` | | `axiomcode path ` | how A reaches B: every hop of the call chain, with the code at each call | diff --git a/bin/axiomcode b/bin/axiomcode index 6b902cf0..873252f4 100755 --- a/bin/axiomcode +++ b/bin/axiomcode @@ -1,7 +1,7 @@ #!/usr/bin/env bash # ───────────────────────────────────────────────────────────────────────────── # axiomcode — ask a repository's call graph. `axiomcode --help` prints the dispatcher's help -# (plugins/axiomcode/skills/axiomcode/scripts/axiomcode): index, find, impact, path and tests. +# (plugins/axiomcode/skills/axiomcode/scripts/axiomcode): index, impact, path and tests. # ───────────────────────────────────────────────────────────────────────────── # INTERNAL COMMANDS, not advertised: the build, the engine suites and the MCP server, which # the dispatcher, the test suites and the agent manifests call. diff --git a/plugins/axiomcode/skills/axiomcode/SKILL.md b/plugins/axiomcode/skills/axiomcode/SKILL.md index 9deac574..2516f40b 100644 --- a/plugins/axiomcode/skills/axiomcode/SKILL.md +++ b/plugins/axiomcode/skills/axiomcode/SKILL.md @@ -12,7 +12,7 @@ Search with grep as usual; the graph answers what grep cannot. Use the MCP tools | the question | MCP tool | shell | |---|---|---| -| where is the code for this task? | grep; or, shell only | `axiomcode find ""` | +| where is the code for this task? | your own search (grep), then bring the name here | — | | who calls X, what does changing it reach, which tests? | `impact(name)` | `axiomcode impact ` | | what do my uncommitted edits reach? | `impact()` | `axiomcode impact` | | how does A reach B? | `path(start, end)` | `axiomcode path ` | @@ -40,12 +40,6 @@ is: `resolved` is an edge the engine resolved and re-checked (`verified:`), do n `hop N` is how far out it is. A call the graph could not resolve is *unknown*, not absent: never report "no callers" from an empty answer. -## find - -Where the code for a task lives, when you have a task in words and no name yet: the functions involved, most -relevant first, each with its code. A name the code calls but nothing declares is listed with its call sites — that is -code you have to write. Shell only: `axiomcode find "how is the invoice total computed"`. - ## impact With a name: who calls it, what depends on it further out, and the tests that exercise it. Example: diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_text.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_text.py index ada6242b..3ef55ac3 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_text.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_text.py @@ -644,6 +644,13 @@ def block(repo, asked, scope=None, why='unresolved', rows=ROWS): if rest > 0: lines.append(f" … +{rest} more line(s): git grep -n{'w' if w else ''} -F -e '{n}'" if not pat else f" … +{rest} more line(s): git grep -nP -e '{pat.pattern}'") + # a name no graph declares that ARRIVES THROUGH AN IMPORT is the signature of an unstaged dependency, the + # commonest setup gap there is: without the hint this answer is indistinguishable from an engine gap, and the + # fix (stage the dependency's source) is one flag away. Only for undeclared names: a string or a resolved + # target wants no staging advice. + if why == 'undeclared' and any(re.match(r'\s*(import\b|using\b|from\s)', t.strip()) and n in t for _f, _l, t in hits): + lines.append(f" this name arrives through an import, so it is likely declared in a dependency: calls through " + f"it resolve once that dependency's source is staged — `axiomcode index --library `") if not lines: return '' if approxed: lines.append("next: [approx] rows are text placed in the declaration that holds it, not resolved edges: read the " "evidence line, then `impact ` for what a change reaches") diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode index cfcaf9ff..445a508d 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode @@ -2,8 +2,6 @@ # axiomcode — ask the repository's call graph. Each answer is a numbered list of places, each with the code of the # function it sits in. # -# axiomcode find "" -# where the code for a task lives: the functions involved, most relevant first. # axiomcode impact [] # who calls it, what a change to it reaches, and the tests that exercise it. # With no name: the same for the declarations your uncommitted edits changed. @@ -133,7 +131,7 @@ helptext(){ awk 'NR>1 && /^#/ {sub(/^# ?/, ""); print; next} NR>1 {exit}' "$0"; # leading comment block. The verb documents itself once, where it is implemented. verbhelp(){ # a verb of the small surface is explained by its own entry in the help above: what it answers, no options - case "$1" in find|impact|path|tests) + case "$1" in impact|path|tests) helptext | awk -v v="$1" '$0 ~ "^ axiomcode "v"( |$)" {on=1; print; next} on && /^ axiomcode / {exit} on && /^$/ {exit} on {print}' return 0 ;; esac @@ -218,9 +216,9 @@ done # shapes are one answer. Without it the answer is the verb's own, unchanged. G=() case "$cmd" in context|path|impact|test-impact|tests) [ -n "${GREP:-}" ] && G=(python3 "$H/ax_grep.py" "$cmd" "$FR" --limit "${GREP_LIMIT:-30}" --) ;; esac -# THE SMALL SURFACE: find, impact, path and tests, asked with no flags at the front door (the installed `axiomcode` and +# THE SMALL SURFACE: impact, path and tests, asked with no flags at the front door (the installed `axiomcode` and # the MCP server set AXIOMCODE_FRONT), answer as numbered places, each with the code of the function it sits in -# (ax_blocks.py), so a place needs no read to be understood. find is context; impact is impact with its tests, +# (ax_blocks.py), so a place needs no read to be understood. impact is impact with its tests, # and impact with no name answers for the declarations the working tree has edited; path is path; tests is # test-impact. A flag, AXIOMCODE_RAW, or a caller that runs this script directly (the hooks, the suites) gets the # verb's own answer. diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build index 60068b7b..abd5d098 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build @@ -495,12 +495,16 @@ w = max([len('tier')] + [len(t) for t, _ in rows]) print(f"{'tier':<{w}} edges") for t, n in rows: print(f"{t:<{w}} {n}") PY - 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. + # export the graph's facts for impact IN THE BACKGROUND, after the graph is queryable: the first query pays it otherwise + # (and for the plugin that first query is inside hooks/changes.py's timeout=14), but on a large repository the export + # takes minutes and `index` must not wait on it. fd 9 is the build lock: the child closes it, so the lock is released + # when this build ends, and a query or a second build never waits on the warm-up. Its writes are atomic renames. + ( exec 9>&-; nohup python3 "$H/axiomcode-impact" --warm "$REPO" >/dev/null 2>&1 & ) || true + echo "graph ready; precomputing impact facts in the background" # the query rules: their compile started at the top of the build, detached (a caller's timeout cannot kill it half-way, # which cached nothing and repeated on every edit); this only says where it is. A query never waits for it. python3 "$H/dl_program.py" --no-wait || true - [ -n "$BASE_SAVED" ] && AXIOMCODE_GRAPH="$REPO/.axiomcode/base" python3 "$H/axiomcode-impact" --warm "$REPO" >/dev/null 2>&1 || true + [ -n "$BASE_SAVED" ] && ( exec 9>&-; AXIOMCODE_GRAPH="$REPO/.axiomcode/base" nohup python3 "$H/axiomcode-impact" --warm "$REPO" >/dev/null 2>&1 & ) || true [ -z "$OTHER_LANGS" ] || [ ! -s "$PENDING" ] || echo "$LANG_ARG graph ready at $OUT/graph.sqlite; still building: $(cut -d' ' -f1 "$PENDING" | paste -sd, - | sed 's/,/, /g')" } # EVERY OTHER LANGUAGE'S GRAPH, indexed where the engine wrote it and only then moved into place, as the main one is. diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index a7fd4f7a..2e030faa 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -1322,8 +1322,17 @@ class Impact: # a cache written by an older program, or one an interrupted run left half written, breaks every query with # "Cannot open fact file X.facts": accept it only when it holds every relation the program reads need = set(re.findall(r'\.decl (\w+)\([^)]*\)\s+\.input', open(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'dl', 'impact.dl')).read())) - PER_QUERY - if os.path.exists(stamp) and open(stamp).read() == want and all(os.path.exists(os.path.join(D, n + '.facts')) for n in need): return D - os.makedirs(D, exist_ok=True); W = lambda n, rows: g.write(n, rows, D) + def fresh(): + try: return open(stamp).read() == want and all(os.path.exists(os.path.join(D, n + '.facts')) for n in need) + except OSError: return False + if fresh(): return D + os.makedirs(D, exist_ok=True) + if not P.export_lock(D, fresh): return D # another process just exported these same facts + try: return self._export(D, stamp, want) + finally: P.export_unlock(D) + + def _export(self, D, stamp, want): + g = self.g; W = lambda n, rows: g.write(n, rows, D) # owner display -> type id. A display name is NOT unique: two files of one test suite commonly # declare a class of the same name, and keyed on the name alone the second one's members were # filed under the first one's type. Everything scope/2 reaches then crossed between them -- @@ -1558,7 +1567,8 @@ class Impact: L = self.code(r['file']); text = L[r['line'] - 1] if r['line'] <= len(L) else '' for qn in re.findall(rf'([A-Za-z_$][\w$]*)\s*\.\s*{re.escape(r["name"])}\b', text): quals.append((r['file'], r['line'], r['name'], qn)) W('ref', refs); W('qualifier', sorted(set(quals))) - W('registration', ax_registration.registrations(g.q, g.site_file)) + regs = ax_registration.registrations(g.q, g.site_file) # computed once: reg_key_fact below reads it too + W('registration', regs) trs = []; trf = set() # a reference on a module-level type alias's own lines belongs to the alias, not the module (#784) import graph_sql @@ -1640,7 +1650,7 @@ class Impact: regk = [] # `rd` and not `decl`: `decl` is the dec_literal list above for rd, _f, _l, kind, key, _why in (ax_registration.decoration_keys(g.q, g.site_file) + ax_registration.value_route_registrations(g.q, g.site_file) - + ax_registration.registrations(g.q, g.site_file) + tregs): + + regs + tregs): if rd in g.sym and key: regk.append((rd, kind, key)) W('reg_key_fact', sorted(set(regk))) # the callables in test files: a test that publishes a handler table's key drives the handler, and is not diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index index 0aa79c54..2f08ca83 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index @@ -706,6 +706,12 @@ CREATE INDEX IF NOT EXISTS symbols_name ON symbols(name); CREATE INDEX IF NOT EX CREATE INDEX IF NOT EXISTS symbols_file ON symbols(file, line); CREATE INDEX IF NOT EXISTS symbols_mid ON symbols(method_id); CREATE INDEX IF NOT EXISTS symbols_tid ON symbols(type_id); CREATE INDEX IF NOT EXISTS refs_name ON refs(name); CREATE INDEX IF NOT EXISTS type_refs_name ON type_refs(name); CREATE INDEX IF NOT EXISTS decorations_owner ON decorations(owner_id); CREATE INDEX IF NOT EXISTS literals_value ON literals(value); CREATE INDEX IF NOT EXISTS comments_file ON comments(file); CREATE INDEX IF NOT EXISTS ce_callee ON call_edges(callee_method_id); CREATE INDEX IF NOT EXISTS ce_caller ON call_edges(caller_id); +-- lookups by id and by file + line: without them each per-site or per-test query scans the whole table +CREATE INDEX IF NOT EXISTS symbols_id ON symbols(id); CREATE INDEX IF NOT EXISTS refs_file_line ON refs(file, line); +CREATE INDEX IF NOT EXISTS literals_file_line ON literals(file, line); +-- the fields a caller reads: via_base_rows asks per single-target call site, and without the index each ask +-- scanned the whole table (65,797 rows × 5,162 sites on an 8,619-file repository = 24 s of a 171 s warm → 1.4 s) +CREATE INDEX IF NOT EXISTS fa_caller ON field_access(caller_id); -- who calls a method: one row per (site, target), with the engine's tier. caller_id is symbols.id: a -- Java call written in a field initializer has the TYPE as its caller, and that row is kept, not dropped CREATE VIEW callers AS diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-install b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-install index a3a9f80b..918e5d7e 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-install +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-install @@ -43,9 +43,11 @@ Ask it directly, through the axiomcode MCP tools (`mcp__plugin_axiomcode_axiomco path(start="", end="") # how A reaches B tests() # the tests your edits reach, and how to run them -Only when those tools are not in your list, the same from the shell: `axiomcode find ""`, +Only when those tools are not in your list, the same from the shell: `axiomcode impact `, `axiomcode path `, `axiomcode tests`. +Finding where code lives is yours: search as you normally would, then bring the name you found here. + **Trust the answer.** Each place comes with the code of the function it sits in: answer from it. A `resolved` place has already been looked up again in the graph (the `verified:` line) — do not re-derive it by grepping. `by name` / `text` places are leads, not facts. An unresolved call means *unknown*, not *absent*. diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index ef352e20..ce1c3772 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -128,6 +128,43 @@ def replace_file(tmp, dst): shutil.copyfile(tmp, dst); os.remove(tmp) +def export_lock(d, fresh): + """ONE exporter per facts directory. A query that found the stamp stale while another process (the build's + background warm-up, a concurrent query, several hooks at once) was already writing these facts ran the whole + export AGAIN beside it: on an 8,619-file repository the first query after `index` cost 193 s against 8.8 s + once warm, every concurrent query the same again, and the copies thrashed each other. Returns True with the + lock held — the caller exports, then export_unlock in a finally — or False once another process finished the + same export (`fresh` says so) and the caller just reads it. A lock whose writer is gone is taken over.""" + lock = os.path.join(d, '.exporting') + while True: + try: + fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY) + os.write(fd, str(os.getpid()).encode()); os.close(fd); return True + except FileExistsError: + pass + gone = False + try: + os.kill(int(open(lock).read().strip() or '0'), 0) + except (ProcessLookupError, ValueError): + gone = True + except PermissionError: + pass # alive, another user's process: wait for it + except OSError: # Windows cannot probe another session's pid: fall back to the lock's age + try: gone = time.time() - os.path.getmtime(lock) > 900 + except OSError: continue # the lock vanished under us: contend for it again + if gone: + try: os.remove(lock) + except OSError: pass + continue + time.sleep(0.5) + if fresh(): return False + + +def export_unlock(d): + try: os.remove(os.path.join(d, '.exporting')) + except OSError: pass + + class G: def __init__(self, repo): self.repo = os.path.realpath(repo or '.') @@ -932,10 +969,16 @@ class G: unfoll = [(r['lib'], r['n']) for r in rows if r['from_site']][:cap] return named, unfoll def site_file(self, raw): - """a call site's file as the repo sees it: Java bundles store absolute paths; the index's paths table maps them""" - if self.has('paths'): - r = self.q("SELECT rel FROM paths WHERE raw = ?", raw) - if r: return r[0]['rel'] + """a call site's file as the repo sees it: Java bundles store absolute paths; the index's paths table maps them. + The map is read ONCE PER GRAPH, not once per row: this ran a query per call (one `has`, one lookup), and the + facts export calls it for every call edge — 319k edges on an 8,619-file repository made 638k round-trips + through sqlite, which was most of the export's time (reg_key_fact 36 s, calls 34 s, registration 31 s, + via_base 25 s of a 171 s warm).""" + m = getattr(self, '_site_files', None) + if m is None: + m = self._site_files = {r['raw']: r['rel'] for r in self.q("SELECT raw, rel FROM paths")} if self.has('paths') else {} + got = m.get(raw) + if got is not None: return got return os.path.relpath(raw, self.repo).replace(os.sep, '/') if os.path.isabs(raw) and raw.startswith(self.repo) else raw # ── facts: exported once per graph, reused while graph.sqlite is unchanged ─────────────────────────────── @@ -948,8 +991,16 @@ class G: EXPORT_VERSION = '4' def export(self): stamp = os.path.join(self.facts, 'stamp'); want = f"{self.db_mtime}:{self.EXPORT_VERSION}" - if os.path.exists(stamp) and open(stamp).read() == want: return + def fresh(): + try: return open(stamp).read() == want + except OSError: return False + if fresh(): return os.makedirs(self.facts, exist_ok=True) + if not export_lock(self.facts, fresh): return # another process just exported these same facts + try: self._export(stamp, want) + finally: export_unlock(self.facts) + + def _export(self, stamp, want): # `generated` is a client declaration too: a Lombok accessor, a record member, a C# auto-property. The engine # synthesises it and RESOLVES the call sites that name it, so filtering the edge out lost every call into a # generated member — which, on a project whose models are all @Data, is most of what touches the models. @@ -999,6 +1050,9 @@ class G: for r in rows: f.write('\t'.join(str(x) for x in r) + '\n') replace_file(tmp, p) def edges(self): + # exported here, not only by the verbs that call export(): `context` read edge.facts straight after `index` + # returned, while the build's warm-up was still writing it in the background, and died on a missing file + self.export() return [tuple(l.rstrip('\n').split('\t')) for l in open(os.path.join(self.facts, 'edge.facts'))] + list(self.EXTRA) def add_outside_call_edges(self): """THE HOPS NO CALL SITE EXPRESSES, as hops of this query (#1469). A gRPC client reaches the handler that serves diff --git a/skills/axiomcode/SKILL.md b/skills/axiomcode/SKILL.md index bbc24624..ae477b92 100644 --- a/skills/axiomcode/SKILL.md +++ b/skills/axiomcode/SKILL.md @@ -6,13 +6,13 @@ description: >- # axiomcode -Four questions, asked of the repository's call graph. Use the MCP tools when they are in your list (in Claude Code +Search with grep as usual; the graph answers what grep cannot. Use the MCP tools when they are in your list (in Claude Code `mcp__plugin_axiomcode_axiomcode__impact`, `__path`, `__tests`); otherwise run `/../../plugins/axiomcode/skills/axiomcode/scripts/axiomcode ` from the repository root. Same answer either way. | the question | MCP tool | shell | |---|---|---| -| where is the code for this task? | grep; or, shell only | `axiomcode find ""` | +| where is the code for this task? | your own search (grep), then bring the name here | — | | who calls X, what does changing it reach, which tests? | `impact(name)` | `axiomcode impact ` | | what do my uncommitted edits reach? | `impact()` | `axiomcode impact` | | how does A reach B? | `path(start, end)` | `axiomcode path ` | @@ -40,12 +40,6 @@ is: `resolved` is an edge the engine resolved and re-checked (`verified:`), do n `hop N` is how far out it is. A call the graph could not resolve is *unknown*, not absent: never report "no callers" from an empty answer. -## find - -Where the code for a task lives, when you have a task in words and no name yet: the functions involved, most -relevant first, each with its code. A name the code calls but nothing declares is listed with its call sites — that is -code you have to write. Shell only: `axiomcode find "how is the invoice total computed"`. - ## impact With a name: who calls it, what depends on it further out, and the tests that exercise it. Example: diff --git a/tests/export_singleflight.py b/tests/export_singleflight.py new file mode 100644 index 00000000..61ad051e --- /dev/null +++ b/tests/export_singleflight.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""tests/export_singleflight.py — one facts export per graph, however many queries arrive at once. + +The facts a query solves over (out/dl, out/dl/impact) are exported once per graph.sqlite and reused. The export +had no lock: a query that found the stamp stale while another process was already writing the same facts — the +build's background warm-up, a second query, several hooks at once — ran the WHOLE export again beside it. On an +8,619-file repository the first query after `index` cost 193 s against 8.8 s warm, and every concurrent query +paid the same again. Now the first comer takes out/dl/impact/.exporting and the rest wait for its stamp; a lock +whose writer died is taken over. + +Checks, on one indexed fixture: + 1. wait: a lock held by a LIVE process makes a stale --warm wait, and when the holder restores the fresh + stamp and releases, the waiter returns WITHOUT re-exporting (the stamp file is untouched). + 2. takeover: a lock left by a DEAD process does not block — the next --warm removes it and exports. + 3. unlock: a finished export leaves no .exporting behind. + +Indexes one case, so it needs the engine (AXIOMCODE_ENGINE, as tests/run.py). + + python3 tests/export_singleflight.py +""" +import os, shutil, subprocess, sys, tempfile, threading, time + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +AX = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts', 'axiomcode') +IMPACT = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts', 'axiomcode-impact') +CASE = os.path.join(ROOT, 'tests', 'cases', 'java', 'containment-not-from-a-shared-span', 'src') + + +def run(*a, timeout=600): + p = subprocess.run(['bash', AX] + list(a), capture_output=True, text=True, timeout=timeout) + return p.returncode, p.stdout + p.stderr + + +def main(): + fails = [] + def check(ok, why, detail=''): + print(('ok ' if ok else 'FAIL ') + why + ('' if ok else '\n ' + detail.strip().replace('\n', '\n '))) + if not ok: fails.append(why) + + work = tempfile.mkdtemp(prefix='axiomcode-singleflight-') + try: + repo = os.path.join(work, 'repo'); shutil.copytree(CASE, repo) + rc, out = run('index', repo, '--lang', 'java') + if rc: print(f"FAIL index: {out[-300:]}"); return 1 + rc, out = run('impact', '--warm', repo) + check(rc == 0 and 'impact facts ready' in out, 'a first --warm exports the facts', out[-300:]) + D = os.path.join(repo, '.axiomcode', 'out', 'dl', 'impact') + stamp = os.path.join(D, 'stamp'); lock = os.path.join(D, '.exporting') + check(not os.path.exists(lock), 'a finished export leaves no .exporting behind', 'lock file still there') + + # 1. WAIT, DON'T RE-EXPORT: hold the lock as a live process, hide the stamp so the waiter sees stale facts, + # then put the fresh stamp back and release. The waiter must return 0 having written nothing: the stamp's + # mtime is the proof, set well in the past so any rewrite moves it. + held = open(lock, 'w'); held.write(str(os.getpid())); held.flush() + past = time.time() - 3600; os.utime(stamp, (past, past)); before = os.path.getmtime(stamp) + saved = stamp + '.aside'; os.rename(stamp, saved) + + def release(): + time.sleep(2); os.rename(saved, stamp); held.close(); os.remove(lock) + t = threading.Thread(target=release); t.start() + t0 = time.time(); rc, out = run('impact', '--warm', repo, timeout=120); waited = time.time() - t0 + t.join() + check(rc == 0, 'a --warm against a held lock returns 0 once the holder finishes', out[-300:]) + check(waited >= 2, f'it waited for the holder ({waited:.1f}s)', 'returned before the lock was released') + check(os.path.getmtime(stamp) == before, 'and re-exported nothing: the stamp is the one the holder left', + 'stamp mtime moved — the waiter exported over the holder') + + # 2. TAKEOVER: a dead writer's lock does not block. Plant a lock naming a pid that is gone, stale the + # stamp for real, and the next --warm must remove the lock and export. + os.remove(stamp) + open(lock, 'w').write('999999999') + t0 = time.time(); rc, out = run('impact', '--warm', repo, timeout=120) + check(rc == 0 and os.path.exists(stamp), 'a dead writer\'s lock is taken over and the export runs', out[-300:]) + check(not os.path.exists(lock), 'and the taken-over lock is released', 'lock file still there') + finally: + shutil.rmtree(work, ignore_errors=True) + + print(('FAIL: ' + '; '.join(fails)) if fails else 'all export_singleflight checks passed') + return 1 if fails else 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tests/freshness.py b/tests/freshness.py index 08bcd425..819e0a44 100644 --- a/tests/freshness.py +++ b/tests/freshness.py @@ -824,13 +824,13 @@ def mcp_checks(): with contextlib.redirect_stderr(io.StringIO()): spec.loader.exec_module(m) # THE SMALL SURFACE takes no options: freshness is the dispatcher's own (a query waits briefly, or answers from the # last graph and says so), so no tool takes fresh or refresh, and none passes --fresh or --no-refresh - tools = ('find', 'impact', 'path', 'tests') + tools = ('impact', 'path', 'tests') check("mcp: no tool takes fresh or refresh", not any(p in m.PARAMS.get(t, []) for t in tools for p in ('fresh', 'refresh')) and all(t in m.PARAMS for t in tools), {t: m.PARAMS.get(t) for t in tools}) seen = [] m.run = lambda args, *a, **k: seen.append(args) or '' - m.find('t'); m.impact('X'); m.impact(); m.path('A', 'B'); m.tests() - check("mcp: no tool passes --fresh or --no-refresh", len(seen) == 5 and not any(a in s for s in seen for a in ('--fresh', '--no-refresh')), seen) + m.impact('X'); m.impact(); m.path('A', 'B'); m.tests() + check("mcp: no tool passes --fresh or --no-refresh", len(seen) == 4 and not any(a in s for s in seen for a in ('--fresh', '--no-refresh')), seen) check("mcp: fresh=true is refused as an unknown argument, not dropped", 'fresh: unexpected argument' in (m.unknown_arguments('impact', {'name': 'X', 'fresh': True}) or ''), m.unknown_arguments('impact', {'name': 'X', 'fresh': True})) diff --git a/tests/manifests.py b/tests/manifests.py index 24310091..59a15329 100644 --- a/tests/manifests.py +++ b/tests/manifests.py @@ -172,7 +172,7 @@ def gemini_path(value): if re.search(r'(? 26 else lines[14:]))) elif verbose: print(f"ok {l}/{name}: {ch['why']}") if not keep: shutil.rmtree(os.path.join(path, '.axiomcode'), ignore_errors=True) return buf.getvalue(), tot, fail, pend diff --git a/tests/surfaces.py b/tests/surfaces.py index 2ff43816..7e550df0 100644 --- a/tests/surfaces.py +++ b/tests/surfaces.py @@ -29,13 +29,13 @@ MCP = os.path.join(PLUG, 'mcp', 'server.py') CLI = os.path.join(ROOT, 'bin', 'axiomcode') # the command an install puts on $PATH -PUBLIC = ['index', 'find', 'impact', 'path', 'tests'] -NO_MCP = {'index': 'setup, not a question: the first query through the MCP server builds the graph itself', - 'find': 'search is the agent\'s own grep, which a hook annotates; find is a shell verb for a person'} +PUBLIC = ['index', 'impact', 'path', 'tests'] +NO_MCP = {'index': 'setup, not a question: the first query through the MCP server builds the graph itself'} # dispatched, not advertised: verb -> why INTERNAL = { 'build': 'the old name of index', - 'context': 'what find runs; its flags (--in, --source, --from, …) serve the hooks and the suites', + 'context': 'search by task words; search is the agent\'s own grep, which a hook annotates', + 'find': 'the front-door spelling of context; dispatched for compatibility, no longer advertised', 'changed': 'impact with no name answers the same question at the front door; the edit hooks read it with --json', 'test-impact': 'what tests runs; its flags (--range, --staged, --why, …) serve scripts and the suites', 'graph': 'draws the graph as a page for a person; not one of the four questions',