fix(eval): align review metrics and corpus evidence

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Gergo Magyar 2026-09-04 05:38:25 +00:00
parent 6925fb344d
commit 9047bf00a5
32 changed files with 5529 additions and 13132 deletions

View file

@ -184,8 +184,13 @@ def test_clean_control_rewards_an_empty_approval_and_penalizes_noise():
(),
)
assert clean["weighted_f1"] == 1
assert clean["weighted_f1"] is None
assert clean["precision"] is None
assert clean["recall"] is None
assert clean["clean_pass"] is True
assert clean["verdict_correct"] is True
assert noisy["false_positives"] == 1
assert noisy["weighted_precision"] == 0
assert noisy["recall"] is None
assert noisy["clean_pass"] is False
assert noisy["verdict_correct"] is False

View file

@ -295,12 +295,13 @@ def test_review_gate_rejects_added_false_positives_on_clean_controls():
"review_weighted_f1": 1.0,
"review_blocker_recall": 1.0,
"review_clean_control": True,
"review_clean_pass": True,
}
decision = evaluate_review_candidate(
{
"clean": {
"review": {**base, "review_false_positives": 0},
"candidate_review": {**base, "review_false_positives": 1},
"candidate_review": {**base, "review_false_positives": 1, "review_clean_pass": False},
}
},
incumbent_arm="review",

View file

@ -528,6 +528,8 @@ def evaluate_review_candidate(
incumbent_fp = incumbent.get("review_false_positives")
candidate_fp = candidate.get("review_false_positives")
clean = bool(incumbent.get("review_clean_control", candidate.get("review_clean_control", False)))
incumbent_clean_pass = incumbent.get("review_clean_pass")
candidate_clean_pass = candidate.get("review_clean_pass")
task_rows.append(
{
"task": task_id,
@ -539,6 +541,8 @@ def evaluate_review_candidate(
"incumbent_false_positives": incumbent_fp,
"candidate_false_positives": candidate_fp,
"clean_control": clean,
"incumbent_clean_pass": incumbent_clean_pass,
"candidate_clean_pass": candidate_clean_pass,
}
)
if (
@ -552,21 +556,30 @@ def evaluate_review_candidate(
f"{task_id}: needs {min_runs} valid paired runs with zero exclusions "
f"(got {incumbent_runs}/{candidate_runs})"
)
values = (incumbent_score, candidate_score, incumbent_blockers, candidate_blockers, incumbent_fp, candidate_fp)
if any(value is None for value in values):
required_values = (
(incumbent_fp, candidate_fp, incumbent_clean_pass, candidate_clean_pass)
if clean
else (incumbent_score, candidate_score, incumbent_fp, candidate_fp)
)
if any(value is None for value in required_values):
insufficient = True
reasons.append(f"{task_id}: structured review quality metrics are incomplete")
continue
if float(candidate_blockers) < float(incumbent_blockers):
if incumbent_blockers is not None and candidate_blockers is not None and float(candidate_blockers) < float(
incumbent_blockers
):
regression = True
reasons.append(f"{task_id}: blocker recall regressed")
if clean and float(candidate_fp) > float(incumbent_fp):
regression = True
reasons.append(f"{task_id}: false positives increased on a clean control")
if float(candidate_score) + 1e-9 < float(incumbent_score):
if clean and bool(incumbent_clean_pass) and not bool(candidate_clean_pass):
regression = True
reasons.append(f"{task_id}: clean-control verdict regressed")
if not clean and float(candidate_score) + 1e-9 < float(incumbent_score):
regression = True
reasons.append(f"{task_id}: weighted review score regressed")
if float(candidate_score) >= float(incumbent_score) + min_improvement:
if not clean and float(candidate_score) >= float(incumbent_score) + min_improvement:
improvement = True
if insufficient:

View file

@ -205,6 +205,7 @@ def compact_row(row: dict[str, Any]) -> dict[str, Any]:
"grounded_evidence",
"verdict_correct",
"clean_control",
"clean_pass",
)
}
if isinstance(row.get("review_score"), dict)

View file

@ -0,0 +1,11 @@
{
"schema_version": 1,
"findings": [
{"id":"pr2108-leakage-classified-as-plateau","severity":"medium","path":"gitnexus/scripts/bench/fts-evict-reload-rss.mjs","line_start":364,"line_end":364,"category":"correctness"},
{"id":"pr2108-batch-failure-silently-demotes-symbols","severity":"medium","path":"gitnexus/src/mcp/local/local-backend.ts","line_start":1134,"line_end":1178,"category":"correctness"},
{"id":"pr2108-build-only-env-leaks-to-runtime","severity":"low","path":"Dockerfile.cli","line_start":88,"line_end":88,"category":"other"},
{"id":"pr2108-native-benchmark-bypasses-production-open","severity":"medium","path":"gitnexus/scripts/bench/fts-evict-reload-rss.mjs","line_start":122,"line_end":186,"category":"tests"},
{"id":"pr2108-unpinned-build-extension","severity":"medium","path":"Dockerfile.cli","line_start":86,"line_end":89,"category":"security"},
{"id":"pr2108-verify-flag-parsed-as-extension","severity":"low","path":"gitnexus/scripts/install-duckdb-extension.mjs","line_start":59,"line_end":59,"category":"correctness"}
]
}

View file

@ -0,0 +1 @@
{"schema_version":1,"findings":[]}

View file

@ -0,0 +1,7 @@
{
"schema_version": 1,
"findings": [
{"id":"pr2258-vacuous-gated-recall-pass","severity":"medium","path":"gitnexus/bench/impact-pdg/gate-mutation-recall.mjs","line_start":20,"line_end":29,"category":"tests"},
{"id":"pr2258-stale-child-invocation-doc","severity":"low","path":"gitnexus/bench/impact-pdg/README.md","line_start":233,"line_end":233,"category":"other"}
]
}

View file

@ -0,0 +1,12 @@
{
"schema_version": 1,
"findings": [
{"id":"pr2718-multiline-closure-join","severity":"high","path":"gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts","line_start":212,"line_end":212,"category":"correctness"},
{"id":"pr2718-constructor-parameter-property-identity","severity":"high","path":"gitnexus/src/core/ingestion/workers/parse-worker.ts","line_start":2310,"line_end":2310,"category":"correctness"},
{"id":"pr2718-dart-top-level-closures","severity":"high","path":"gitnexus/src/core/ingestion/languages/dart/query.ts","line_start":109,"line_end":109,"category":"correctness"},
{"id":"pr2718-ruby-closure-forms","severity":"high","path":"gitnexus/src/core/ingestion/languages/ruby/query.ts","line_start":106,"line_end":106,"category":"correctness"},
{"id":"pr2718-dart-signature-assumption","severity":"medium","path":"gitnexus/src/core/ingestion/utils/ast-helpers.ts","line_start":1283,"line_end":1283,"category":"correctness"},
{"id":"pr2718-receiver-qualified-lambda","severity":"low","path":"gitnexus/src/core/ingestion/languages/ruby/query.ts","line_start":104,"line_end":104,"category":"correctness"},
{"id":"pr2718-rust-closure-source-coverage","severity":"medium","path":"gitnexus/src/core/ingestion/tree-sitter-queries.ts","line_start":1346,"line_end":1346,"category":"tests"}
]
}

View file

@ -0,0 +1 @@
{"schema_version":1,"findings":[]}

View file

@ -0,0 +1,11 @@
{
"schema_version": 1,
"findings": [
{"id":"pr2794-module-global-retains-parsed-files","severity":"high","path":"gitnexus/src/core/ingestion/languages/cpp/inline-namespaces.ts","line_start":147,"line_end":147,"category":"performance"},
{"id":"pr2794-benchmark-misses-receiver-regression","severity":"high","path":"gitnexus/bench/cpp-qualified-ns/measure.mjs","line_start":142,"line_end":142,"category":"tests"},
{"id":"pr2794-recursive-quadratic-namespace-walk","severity":"medium","path":"gitnexus/src/core/ingestion/languages/cpp/inline-namespaces.ts","line_start":193,"line_end":223,"category":"performance"},
{"id":"pr2794-scaling-gate-not-wired","severity":"medium","path":".github/workflows/ci-tests.yml","line_start":509,"line_end":509,"category":"tests"},
{"id":"pr2794-cross-file-dedup-coverage","severity":"medium","path":"gitnexus/test/unit/cpp-qualified-ns-index.test.ts","line_start":78,"line_end":78,"category":"tests"},
{"id":"pr2794-false-load-bearing-order-contract","severity":"low","path":"gitnexus/src/core/ingestion/languages/cpp/inline-namespaces.ts","line_start":138,"line_end":138,"category":"other"}
]
}

View file

@ -1,13 +0,0 @@
{
"schema_version": 1,
"findings": [
{
"id": "pr3109-grep-timeout-only-between-files",
"severity": "medium",
"path": "gitnexus/src/server/api.ts",
"line_start": 1368,
"line_end": 1405,
"category": "performance"
}
]
}

View file

@ -1,29 +0,0 @@
{
"schema_version": 1,
"findings": [
{
"id": "pr3111-mid-path-template-truncated",
"severity": "high",
"path": "gitnexus/src/core/group/extractors/http-patterns/node.ts",
"line_start": 241,
"line_end": 275,
"category": "correctness"
},
{
"id": "pr3111-dynamic-method-defaulted-to-get",
"severity": "high",
"path": "gitnexus/src/core/group/extractors/http-patterns/node.ts",
"line_start": 810,
"line_end": 830,
"category": "correctness"
},
{
"id": "pr3111-encoded-brace-literal-collides-with-param",
"severity": "medium",
"path": "gitnexus/src/core/group/extractors/http-route-extractor.ts",
"line_start": 330,
"line_end": 365,
"category": "correctness"
}
]
}

View file

@ -1,4 +0,0 @@
{
"schema_version": 1,
"findings": []
}

View file

@ -1,13 +0,0 @@
{
"schema_version": 1,
"findings": [
{
"id": "pr3124-unreadable-skill-treated-as-missing",
"severity": "high",
"path": "gitnexus/src/cli/ai-context.ts",
"line_start": 447,
"line_end": 452,
"category": "correctness"
}
]
}

View file

@ -1,4 +0,0 @@
{
"schema_version": 1,
"findings": []
}

View file

@ -1,13 +0,0 @@
{
"schema_version": 1,
"findings": [
{
"id": "pr3153-programmatic-cache-bypass-still-returns-up-to-date",
"severity": "high",
"path": "gitnexus/src/core/run-analyze.ts",
"line_start": 1738,
"line_end": 1760,
"category": "correctness"
}
]
}

View file

@ -1,60 +1,12 @@
{
"schema_version": 1,
"corpus_version": "2026-09-04",
"corpus_version": "2026-09-04.2",
"cases": [
{
"id": "review-pr-3124-defect",
"pr": "https://github.com/abhigyanpatwari/GitNexus/pull/3124",
"base_sha": "9f82ffd6bfe59270c6cdec0e2f3314038f4a7faf",
"head_sha": "0b69788790fd14d53868a676b17ce624471359f4",
"patch_sha256": "f5d18d76643b391a73d85b600fc36bfd1ee9ad332c34d68d2e2a6c5def939125",
"human_verification_commit": "ef3c5b2bdd71ab331d2062ba3e6230be49d48b4a",
"label_source": "PR review follow-up commit"
},
{
"id": "review-pr-3153-defect",
"pr": "https://github.com/abhigyanpatwari/GitNexus/pull/3153",
"base_sha": "3c2b14aff59791be0a000160b35f3c5e8f2997a0",
"head_sha": "5eb612cd87171840d6b6bcabda3e43d21180a686",
"patch_sha256": "5740d06f64296a7661dc549079f679713609e16c595e84a540266c4054558b0c",
"human_verification_commit": "c80a8fd9209a1ed43438f7e7a13517fa66501c07",
"label_source": "PR review follow-up commit"
},
{
"id": "review-pr-3109-defect",
"pr": "https://github.com/abhigyanpatwari/GitNexus/pull/3109",
"base_sha": "4aa6bddd0a78135136d29d8440eb29613097f616",
"head_sha": "b9ea906e89b67dc782c3341a53ce40059f2d404f",
"patch_sha256": "6ff009e823791a8209cab2aa0249c1d184967688b33d59f1dc40e25c7a4dae48",
"human_verification_commit": "3caf3369cc367b3360c316da45bd1366003f8308",
"label_source": "PR review follow-up commit"
},
{
"id": "review-pr-3111-defect",
"pr": "https://github.com/abhigyanpatwari/GitNexus/pull/3111",
"base_sha": "3aa62be717a579d4644364d91fdd50c1b8b5c286",
"head_sha": "e50d36a1a64a54c447378de8d8037733c1011d0d",
"patch_sha256": "a9d229f19f752267da0940f8c8403a3ae186b580ae3c1b8c2457a1f6042cb9fe",
"human_verification_commit": "1ee715f8341ae03f828cdf771cde4a7f9c15142f",
"label_source": "PR review follow-up commit"
},
{
"id": "review-pr-3124-clean",
"pr": "https://github.com/abhigyanpatwari/GitNexus/pull/3124",
"base_sha": "9f82ffd6bfe59270c6cdec0e2f3314038f4a7faf",
"head_sha": "ea7cd4ed342070891e4c40d1de0b0d0852bc9f5e",
"patch_sha256": "ff1ad95daef7fda9c3f08a9cd39ef6819180418d167b10b7fba50b00a254ecb6",
"human_verification_commit": "ea7cd4ed342070891e4c40d1de0b0d0852bc9f5e",
"label_source": "merged final PR snapshot"
},
{
"id": "review-pr-3153-clean",
"pr": "https://github.com/abhigyanpatwari/GitNexus/pull/3153",
"base_sha": "3c2b14aff59791be0a000160b35f3c5e8f2997a0",
"head_sha": "6e9e9bf63682619c3e6d1c4e108f091bee1898de",
"patch_sha256": "be15a48f909c26617dd1b75c2b5e3e2ded50380342b23849d474d4d2f7e7c0dc",
"human_verification_commit": "6e9e9bf63682619c3e6d1c4e108f091bee1898de",
"label_source": "merged final PR snapshot"
}
{"id":"review-pr-2718-defect","pr":"https://github.com/abhigyanpatwari/GitNexus/pull/2718","base_sha":"ff86ccf1e79cd7e4175da437ae8aeaf67b64aaa1","head_sha":"cfd2434c6ca0e303ac40a896db798f30505390d5","patch_sha256":"14ca0d5659fb7aa529c32543f194deabc8e25d592037dad1d70ef8f6ab906391","human_verification_commit":"cebe66b33509021de8d09083c461501cb49a460b","label_source":"exact-head tri-review 4799215165"},
{"id":"review-pr-2794-defect","pr":"https://github.com/abhigyanpatwari/GitNexus/pull/2794","base_sha":"911151e2304f298a995fcc69c738ad2c6db9393a","head_sha":"48afb7480778ef2f5e0be455498ace889d03edf9","patch_sha256":"e2271ede4b4d65993abde16012061191a094847aadf0431a2dec908a59b6af93","human_verification_commit":"0015b0d64537c9ac97fda5ed094c99059d596cfc","label_source":"exact-head tri-review 4837878304"},
{"id":"review-pr-2108-defect","pr":"https://github.com/abhigyanpatwari/GitNexus/pull/2108","base_sha":"3a4247ec36b5ad86b1123d3bbce8183a643f7434","head_sha":"fdabb8a1fa441b8fc7f3471121a9fa5a9d885376","patch_sha256":"12928510253fe079b179fc4658334b732de8907471ac92a6ed8061fb78aedbf2","human_verification_commit":"40dff64992ac1e72c7c042b2f508dbdc433f74dd","label_source":"exact-head tri-review 4456060714"},
{"id":"review-pr-2258-defect","pr":"https://github.com/abhigyanpatwari/GitNexus/pull/2258","base_sha":"78b4077d8acc86f1b0c32e41012174d484e81f12","head_sha":"c93ca8ce73db7e4f0b083226a166dd9841288140","patch_sha256":"8e0eb214bd6151d1060cf2c47df7735fe5c57398e08bee9306b7b702abeca843","human_verification_commit":"00e52fa7fbae21668ae818a41f786d529c4ca390","label_source":"exact-head tri-review 4538570459"},
{"id":"review-pr-2258-clean","pr":"https://github.com/abhigyanpatwari/GitNexus/pull/2258","base_sha":"78b4077d8acc86f1b0c32e41012174d484e81f12","head_sha":"00e52fa7fbae21668ae818a41f786d529c4ca390","patch_sha256":"4323ec2ff01f612f18a493e055c70eb4307e9c1859b97f5b60da07b177d3a238","human_verification_commit":"00e52fa7fbae21668ae818a41f786d529c4ca390","label_source":"production-ready tri-review confirmation"},
{"id":"review-pr-2773-clean","pr":"https://github.com/abhigyanpatwari/GitNexus/pull/2773","base_sha":"84f584449de02376a8ffc096dceac2e8f732cab5","head_sha":"f584f83bcb93c91752d91144b251f98d39027180","patch_sha256":"1c0327d4d9725428c1b49ab3586abefdbc084147735b120afd43312e8e48dd91","human_verification_commit":"f584f83bcb93c91752d91144b251f98d39027180","label_source":"review fix confirmation 3694979051"}
]
}

View file

@ -0,0 +1,845 @@
diff --git a/Dockerfile.cli b/Dockerfile.cli
index 3275c8f7e..cfba9bac4 100644
--- a/Dockerfile.cli
+++ b/Dockerfile.cli
@@ -67,6 +67,28 @@ COPY --from=builder --chown=node:node /app/gitnexus/vendor ./gitnexus/vendor
# unreachable from $PATH.
RUN ln -s /app/gitnexus/dist/cli/index.js /usr/local/bin/gitnexus
+# Bake the LadybugDB FTS extension into the image so BM25 keyword search works
+# at runtime. The server runs the default `load-only` extension policy (the read
+# pool pins `{ policy: 'load-only' }`), so a runtime `LOAD EXTENSION fts` never
+# INSTALLs — the extension must already exist in the runtime user's HOME
+# extension dir, or every keyword search silently degrades (no FTS indexes are
+# written and ranking falls back to vector-only with only a `warning` field).
+# Run the installer as the `node` user with the SAME HOME the server runs under,
+# so `INSTALL fts` materializes the extension under `$HOME/.lbdb/extension` where
+# the runtime `LOAD` resolves it offline. `ENV HOME` is pinned because Docker
+# does not derive HOME from `USER`, so without it build-install and runtime-load
+# would resolve different paths. Requires network egress for the one-time
+# INSTALL; the build fails loudly if it cannot fetch the extension. The DB-size
+# default comes from GITNEXUS_LBUG_MAX_DB_SIZE (single source of truth, matches
+# the runtime) — it only sizes the throwaway scratch DB used to run INSTALL.
+# The second `--verify-only` step re-LOADs the extension in a FRESH process
+# under the same HOME, so a HOME/extension-dir mismatch fails the build here
+# rather than silently degrading keyword search to vector-only at runtime.
+ENV HOME=/home/node \
+ GITNEXUS_LBUG_MAX_DB_SIZE=17179869184
+RUN su node -s /bin/sh -c "HOME=/home/node node /app/gitnexus/scripts/install-duckdb-extension.mjs fts" \
+ && su node -s /bin/sh -c "HOME=/home/node node /app/gitnexus/scripts/install-duckdb-extension.mjs fts --verify-only"
+
USER node
# The web UI defaults to http://localhost:4747 - keep that contract.
diff --git a/gitnexus/scripts/bench/fts-evict-reload-rss.mjs b/gitnexus/scripts/bench/fts-evict-reload-rss.mjs
new file mode 100644
index 000000000..6240e16b7
--- /dev/null
+++ b/gitnexus/scripts/bench/fts-evict-reload-rss.mjs
@@ -0,0 +1,421 @@
+#!/usr/bin/env node
+// FTS evict→reload RSS repro (gitnexus-enterprise PR #222 / local U3).
+//
+// Settles ONE empirical question that no static read can answer: when a
+// LadybugDB database that has `LOAD EXTENSION fts` applied is closed and a
+// fresh one is opened + re-LOADed (the pool's evict→reload cycle), does the
+// native FTS arena get reclaimed by `db.close()` — or is it stranded, so RSS
+// climbs without bound over a long-lived MCP `serve` session?
+//
+// • PLATEAU across cycles → db.close() reclaims the FTS arena; the OSS pool's
+// footprint is bounded by MAX_POOL_SIZE (~5 live arenas). No unbounded leak;
+// the #222 worker-isolation rewrite (plan U4) is NOT justified for OSS.
+// • MONOTONIC CLIMB → the FTS arena is stranded per reopen; the user's
+// hypothesis holds and U4 (route FTS reads through a reclaimable worker) is
+// justified.
+//
+// SCOPE OF THE VERDICT (read before citing it). A per-reload FTS-arena leak
+// would be PROPORTIONAL to the index size. A small fixture therefore produces a
+// small per-cycle increment that an absolute threshold can read as PLATEAU even
+// when a production-scale graph would leak visibly. So:
+// - `--rows` controls fixture size; run it LARGE (tens of thousands) before
+// concluding "no leak". The default is deliberately not tiny.
+// - The CLIMB gate combines a per-cycle slope with BOTH an absolute and a
+// per-row-relative delta floor, so the sensitivity scales with fixture size.
+// - The PLATEAU verdict is only valid for the corpus size it was run at; the
+// output states that size. The production-faithful confirmation is a
+// `--via-pool` run against a real large analyzed repo over a long session.
+//
+// Two modes:
+// (default) NATIVE — reproduces the native sequence doInitLbug()+closeOne()
+// perform (open Database → new Connection → LOAD EXTENSION fts →
+// QUERY_FTS_INDEX → close), against K self-built FTS fixtures, with no
+// gitnexus build required. `--no-await-close` mirrors the pool's
+// fire-and-forget close instead of awaiting (the production close shape).
+// --via-pool <lbugPath> — drives the REAL gitnexus pool from compiled dist
+// (initLbug → executeParameterized → closeLbug) against an existing analyzed
+// repo, exercising the production path + the GITNEXUS_POOL_RSS_TRACE
+// instrumentation. Probes ALL FTS indexes the repo has. Forces an explicit
+// close+reinit each cycle. Run `node scripts/build.js` first so the dist
+// reflects the current pool-adapter (incl. the RSS trace).
+//
+// Run with --expose-gc so RSS excludes V8-heap noise:
+// node --expose-gc gitnexus/scripts/bench/fts-evict-reload-rss.mjs
+// node --expose-gc gitnexus/scripts/bench/fts-evict-reload-rss.mjs --rows 40000 --cycles 30
+// GITNEXUS_POOL_RSS_TRACE=1 node --expose-gc \
+// gitnexus/scripts/bench/fts-evict-reload-rss.mjs --via-pool /path/to/repo/.gitnexus/lbug
+//
+// Flags by mode: --rows/--repos/--read-write/--no-await-close apply to NATIVE
+// only; --cycles applies to both. VIA-POOL warns when a NATIVE-only flag is set.
+//
+// Memory benches are noisy. Default is 24 cycles; trust the TREND (slope /
+// first-third vs last-third), never a single delta. A flat trend at a LARGE
+// fixture is a real NEGATIVE result (no unbounded leak), not a failed run.
+
+import { createRequire } from 'node:module';
+import os from 'node:os';
+import path from 'node:path';
+import fs from 'node:fs';
+
+const require = createRequire(import.meta.url);
+const lbugModule = require('@ladybugdb/core');
+const lbug = lbugModule.default ?? lbugModule;
+
+const LBUG_MAX_DB_SIZE = 16 * 1024 * 1024 * 1024;
+
+// ── args ──────────────────────────────────────────────────────────────────
+function argVal(flag, dflt) {
+ const i = process.argv.indexOf(flag);
+ return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : dflt;
+}
+const CYCLES = Math.max(6, parseInt(argVal('--cycles', '24'), 10) || 24);
+const REPOS = Math.max(1, parseInt(argVal('--repos', '6'), 10) || 6); // >5 mirrors LRU thrash
+// Fixture size. Default is large enough that a size-proportional leak would be
+// visible across cycles; raise it further before trusting a PLATEAU verdict.
+const ROWS = Math.max(100, parseInt(argVal('--rows', '8000'), 10) || 8000);
+const VIA_POOL = argVal('--via-pool', null);
+const READONLY = !process.argv.includes('--read-write');
+const AWAIT_CLOSE = !process.argv.includes('--no-await-close');
+
+if (VIA_POOL) {
+ // These flags are consumed only by NATIVE mode; warn rather than ignore
+ // silently so a VIA-POOL run is not misread as honoring them.
+ const ignored = ['--rows', '--repos', '--read-write', '--no-await-close'].filter((f) =>
+ process.argv.includes(f),
+ );
+ if (ignored.length) {
+ console.error(
+ `[fts-rss] NOTE: ${ignored.join(', ')} apply to NATIVE mode only; ignored in --via-pool.`,
+ );
+ }
+}
+
+if (typeof global.gc !== 'function') {
+ console.error(
+ '[fts-rss] WARNING: run with --expose-gc for clean RSS samples ' +
+ '(`node --expose-gc <thisfile>`). Continuing without forced GC — results are noisier.',
+ );
+}
+
+const gc = () => {
+ if (typeof global.gc === 'function') {
+ global.gc();
+ global.gc();
+ }
+};
+const rssMb = () => Math.round(process.memoryUsage().rss / (1024 * 1024));
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+// ── fixture: a minimal FTS-bearing .lbug ────────────────────────────────────
+const WORDS = [
+ 'login auth session token user password validate verify credential',
+ 'parse tree syntax node grammar lexer token ast traversal visitor',
+ 'graph query cypher match relation node edge pattern aggregate index',
+ 'memory pool buffer arena allocate reclaim evict cache resident heap',
+ 'search rank score bm25 fts index stem porter keyword document corpus',
+ 'worker fork process spawn kill reclaim isolate native binding addon',
+];
+
+function buildFixture(dir) {
+ fs.mkdirSync(dir, { recursive: true });
+ const dbPath = path.join(dir, 'fixture.lbug');
+ const db = new lbug.Database(dbPath, 0, false, false, LBUG_MAX_DB_SIZE);
+ const conn = new lbug.Connection(db);
+ return (async () => {
+ await conn.query('LOAD EXTENSION fts');
+ await conn.query(
+ 'CREATE NODE TABLE Doc(id STRING, name STRING, content STRING, PRIMARY KEY(id))',
+ );
+ // Batch-insert via UNWIND so large fixtures (`--rows`) build in seconds
+ // instead of one round-trip per row. The fixture size drives the per-arena
+ // FTS allocation, which is what makes a size-proportional leak observable.
+ const rows = [];
+ for (let i = 0; i < ROWS; i++) {
+ const w = WORDS[i % WORDS.length];
+ const name = `sym_${i}`;
+ const content = `${w} ${name} block number ${i} ${WORDS[(i + 3) % WORDS.length]}`;
+ rows.push({ id: `doc:${i}`, name, content });
+ }
+ const INSERT_CHUNK = 2000;
+ for (let i = 0; i < rows.length; i += INSERT_CHUNK) {
+ const chunk = rows.slice(i, i + INSERT_CHUNK);
+ const stmt = await conn.prepare(
+ 'UNWIND $rows AS r CREATE (:Doc {id: r.id, name: r.name, content: r.content})',
+ );
+ await conn.execute(stmt, { rows: chunk });
+ }
+ await conn.query(
+ "CALL CREATE_FTS_INDEX('Doc', 'doc_fts', ['name', 'content'], stemmer := 'porter')",
+ );
+ await conn.close();
+ await db.close();
+ return dbPath;
+ })();
+}
+
+const QUERIES = ['login token', 'parse node', 'memory arena', 'search index', 'worker reclaim'];
+
+// ── NATIVE mode ─────────────────────────────────────────────────────────────
+async function runNative() {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'fts-rss-'));
+ console.error(
+ `[fts-rss] NATIVE: ${REPOS} fixtures × ${ROWS} rows × ${CYCLES} cycles ` +
+ `(readOnly=${READONLY}, awaitClose=${AWAIT_CLOSE})`,
+ );
+ console.error(`[fts-rss] building ${REPOS} FTS fixture(s) under ${root} …`);
+
+ const srcDb = await buildFixture(path.join(root, 'src'));
+ const repoPaths = [];
+ for (let k = 0; k < REPOS; k++) {
+ const dst = path.join(root, `repo-${k}`);
+ fs.cpSync(path.dirname(srcDb), dst, { recursive: true });
+ repoPaths.push(path.join(dst, 'fixture.lbug'));
+ }
+
+ // Mirror the pool's evict→reload: each visit opens a FRESH Database, makes a
+ // Connection, LOADs fts, runs an FTS query, then closes — no caching, so every
+ // visit is a reload. K>5 amplifies the LRU-thrash signal the pool would see.
+ const series = [];
+ gc();
+ await sleep(50);
+ const baseline = rssMb();
+ console.error(`[fts-rss] baseline RSS=${baseline}MB`);
+
+ for (let cycle = 0; cycle < CYCLES; cycle++) {
+ for (let k = 0; k < REPOS; k++) {
+ const db = new lbug.Database(repoPaths[k], 0, false, READONLY, LBUG_MAX_DB_SIZE);
+ const conn = new lbug.Connection(db);
+ try {
+ await conn.query('LOAD EXTENSION fts'); // the per-reload re-LOAD under test
+ const q = QUERIES[(cycle + k) % QUERIES.length];
+ const res = await conn.query(
+ `CALL QUERY_FTS_INDEX('Doc', 'doc_fts', '${q}') RETURN node.id AS id, score ORDER BY score DESC LIMIT 20`,
+ );
+ // Drain so the query actually materializes results.
+ if (res && typeof res.getAll === 'function') await res.getAll();
+ } catch (e) {
+ console.error(`[fts-rss] query error (cycle ${cycle}, repo ${k}): ${e?.message || e}`);
+ } finally {
+ // AWAIT_CLOSE (default) is the best case for reclamation. --no-await-close
+ // mirrors the pool's fire-and-forget close (closeOne: db.close().catch())
+ // so a leak that only manifests without awaiting is not hidden.
+ if (AWAIT_CLOSE) {
+ try {
+ await conn.close();
+ await db.close();
+ } catch {
+ /* ignore */
+ }
+ } else {
+ conn.close().catch(() => {});
+ db.close().catch(() => {});
+ }
+ }
+ }
+ gc();
+ // Longer settle when not awaiting close, so fire-and-forget native teardown
+ // has a chance to complete before the RSS sample (avoids a false PLATEAU).
+ await sleep(AWAIT_CLOSE ? 20 : 200);
+ const rss = rssMb();
+ series.push(rss);
+ console.error(`[fts-rss] cycle ${String(cycle + 1).padStart(3)}/${CYCLES} rssMB=${rss}`);
+ }
+
+ fs.rmSync(root, { recursive: true, force: true });
+ return { baseline, series, corpus: `${REPOS}×${ROWS} rows, native, awaitClose=${AWAIT_CLOSE}` };
+}
+
+// ── VIA-POOL mode (real gitnexus pool from compiled dist) ───────────────────
+async function runViaPool(lbugPath) {
+ if (!fs.existsSync(lbugPath)) {
+ console.error(`[fts-rss] --via-pool path not found: ${lbugPath}`);
+ process.exit(2);
+ }
+ // Compiled dist is required (the pool pulls the native addon + many modules).
+ const distUrl = new URL('../../dist/core/lbug/pool-adapter.js', import.meta.url);
+ let pool;
+ try {
+ pool = await import(distUrl.href);
+ } catch (e) {
+ console.error(
+ `[fts-rss] could not import compiled pool-adapter (${e?.message}). ` +
+ `Run \`node scripts/build.js\` first, or use NATIVE mode.`,
+ );
+ process.exit(2);
+ }
+ const { initLbug, executeParameterized, closeLbug } = pool;
+ console.error(
+ `[fts-rss] VIA-POOL on ${lbugPath} × ${CYCLES} cycles ` +
+ `(explicit closeLbug+initLbug per cycle = forced evict→reload)`,
+ );
+
+ // Probe ALL FTS indexes the analyzed graph carries (mirrors fts-schema.ts
+ // FTS_INDEXES) so the per-cycle FTS arena load matches production, not a
+ // 2-of-5 subset that would understate it.
+ const FTS_INDEXES = [
+ { table: 'File', indexName: 'file_fts' },
+ { table: 'Function', indexName: 'function_fts' },
+ { table: 'Class', indexName: 'class_fts' },
+ { table: 'Method', indexName: 'method_fts' },
+ { table: 'Interface', indexName: 'interface_fts' },
+ ];
+
+ const series = [];
+ gc();
+ const baseline = rssMb();
+ console.error(`[fts-rss] baseline RSS=${baseline}MB`);
+
+ for (let cycle = 0; cycle < CYCLES; cycle++) {
+ try {
+ await initLbug(lbugPath, lbugPath);
+ const q = QUERIES[cycle % QUERIES.length];
+ for (const { table, indexName } of FTS_INDEXES) {
+ await executeParameterized(
+ lbugPath,
+ `CALL QUERY_FTS_INDEX('${table}', '${indexName}', $q) RETURN node.id AS id, score ORDER BY score DESC LIMIT 20`,
+ { q },
+ ).catch(() => []); // index may not exist for this graph — that's fine
+ }
+ await closeLbug(lbugPath); // force eviction → next cycle reopens + re-LOADs fts
+ } catch (e) {
+ console.error(`[fts-rss] pool cycle ${cycle} error: ${e?.message || e}`);
+ }
+ gc();
+ // closeLbug fires a fire-and-forget native close (pool closeOne:
+ // db.close().catch()), so settle longer than NATIVE's awaited close to let
+ // native teardown finish before sampling — else a real leak reads PLATEAU.
+ await sleep(200);
+ const rss = rssMb();
+ series.push(rss);
+ console.error(`[fts-rss] cycle ${String(cycle + 1).padStart(3)}/${CYCLES} rssMB=${rss}`);
+ }
+ await closeLbug().catch(() => {});
+ return { baseline, series, corpus: `via-pool ${path.basename(path.dirname(lbugPath))}` };
+}
+
+// ── verdict ─────────────────────────────────────────────────────────────────
+function median(xs) {
+ const s = [...xs].sort((a, b) => a - b);
+ const m = Math.floor(s.length / 2);
+ return s.length % 2 ? s[m] : Math.round((s[m - 1] + s[m]) / 2);
+}
+function slopeMbPerCycle(series) {
+ // Least-squares slope of rss vs cycle index.
+ const n = series.length;
+ const xs = series.map((_, i) => i);
+ const xMean = xs.reduce((a, b) => a + b, 0) / n;
+ const yMean = series.reduce((a, b) => a + b, 0) / n;
+ let num = 0,
+ den = 0;
+ for (let i = 0; i < n; i++) {
+ num += (xs[i] - xMean) * (series[i] - yMean);
+ den += (xs[i] - xMean) ** 2;
+ }
+ return den === 0 ? 0 : num / den;
+}
+
+function verdict({ baseline, series, corpus }) {
+ const third = Math.max(1, Math.floor(series.length / 3));
+ const firstMed = median(series.slice(0, third));
+ const lastMed = median(series.slice(-third));
+ const delta = lastMed - firstMed;
+ const slope = slopeMbPerCycle(series);
+ const peak = Math.max(...series);
+
+ // The discriminant between a real leak and allocator warmup is SLOPE
+ // DECELERATION, not total delta. Both a leak and a warmup-to-plateau climb;
+ // they differ in whether the per-cycle increment is SUSTAINED or DECAYS:
+ // - true per-reload leak (stranded FTS arena): RSS rises ~linearly, so the
+ // second-half slope ≈ the first-half slope (the increment does not decay).
+ // - allocator working-set warmup (native free-pool growing to the working
+ // set, freed pages retained-then-reused): RSS rises then flattens, so the
+ // second-half slope is a small FRACTION of the first-half slope. Larger
+ // fixtures warm up over MORE cycles, which a fixed absolute-delta gate
+ // misreads as a leak — the slope ratio is scale-invariant and does not.
+ const half = Math.max(1, Math.floor(series.length / 2));
+ const firstHalfSlope = slopeMbPerCycle(series.slice(0, half));
+ const secondHalfSlope = slopeMbPerCycle(series.slice(-half));
+
+ // Detect a STEP DISCONTINUITY — a single cycle-to-cycle jump far larger than
+ // the typical per-cycle delta. A one-time allocator/arena reservation jump
+ // (then flat) is NOT a per-reload leak, but it inflates the second-half slope
+ // and would fool a pure slope test; it also signals a noisy run.
+ const deltas = series.slice(1).map((v, i) => v - series[i]);
+ const absDeltas = deltas.map(Math.abs).sort((a, b) => a - b);
+ const medAbsDelta = absDeltas.length ? absDeltas[Math.floor(absDeltas.length / 2)] : 0;
+ const maxJump = deltas.length ? Math.max(...deltas) : 0;
+ const stepDiscontinuity = maxJump > Math.max(30, 5 * Math.max(medAbsDelta, 1));
+
+ // The discriminant between a real leak and allocator warmup is SLOPE
+ // DECELERATION, not total delta. A true per-reload leak (stranded FTS arena)
+ // rises ~linearly: the second-half slope stays ≈ the first-half slope. An
+ // allocator working-set warmup rises then flattens: the second-half slope is
+ // a small FRACTION of the first-half. Larger fixtures warm up over MORE
+ // cycles, which a fixed absolute-delta gate misreads as a leak — the slope
+ // ratio is scale-invariant. Below SUSTAIN_FLOOR (~0.5 MB/cycle) the tail is
+ // effectively flat (noise).
+ const SUSTAIN_FLOOR = 0.5;
+ const decelRatio = secondHalfSlope / Math.max(firstHalfSlope, 1e-9);
+ let label;
+ if (stepDiscontinuity) {
+ // A discrete jump (then flat) is not linear accumulation, but the run is
+ // noisy — don't claim a clean result either way.
+ label = 'INCONCLUSIVE';
+ } else if (secondHalfSlope < SUSTAIN_FLOOR) {
+ label = 'PLATEAU';
+ } else if (decelRatio >= 0.6) {
+ label = 'CLIMB';
+ } else {
+ // Tail slope above the flat floor but clearly decelerating — converging,
+ // but not yet flat. Honest answer at this corpus is "not resolved".
+ label = 'INCONCLUSIVE';
+ }
+
+ console.log('\n==================== FTS evict→reload RSS verdict ====================');
+ console.log(`corpus: ${corpus}`);
+ console.log(`samples (MB): ${series.join(' ')}`);
+ console.log(
+ `baseline=${baseline} firstThirdMed=${firstMed} lastThirdMed=${lastMed} delta=${delta}MB ` +
+ `peak=${peak} overallSlope=${slope.toFixed(2)} firstHalfSlope=${firstHalfSlope.toFixed(2)} ` +
+ `secondHalfSlope=${secondHalfSlope.toFixed(2)}MB/cycle maxJump=${maxJump}MB step=${stepDiscontinuity} cycles=${series.length}`,
+ );
+ if (label === 'CLIMB') {
+ console.log(
+ 'VERDICT: CLIMB — the per-cycle increment is SUSTAINED (second-half slope ≈ first-half),\n' +
+ ' i.e. RSS rises ~linearly with no decay. The native FTS arena is NOT reclaimed\n' +
+ ' by db.close(); the leak is real over a long-lived session.\n' +
+ ' → plan U4 (worker/process isolation of the FTS read path) is JUSTIFIED.',
+ );
+ } else if (label === 'PLATEAU') {
+ console.log(
+ `VERDICT: PLATEAU at this corpus (${corpus}) — the per-cycle increment DECAYS to flat\n` +
+ ' (second-half slope below the noise floor). db.close() reclaims the FTS arena;\n' +
+ ' footprint is bounded (and the pool further caps it at MAX_POOL_SIZE). No\n' +
+ ' unbounded leak. Caveat: synthetic fixture — confirm with a --via-pool run\n' +
+ ' against a real large analyzed repo before fully closing plan U4.',
+ );
+ } else {
+ console.log(
+ `VERDICT: INCONCLUSIVE at this corpus (${corpus}) — the run is noisy (step discontinuity)\n` +
+ ' or still decelerating without reaching flat, so neither a clean PLATEAU nor a\n' +
+ ' sustained linear CLIMB can be asserted. NATIVE synthetic runs do not resolve\n' +
+ ' this reliably at scale. The definitive test is a --via-pool run against a real\n' +
+ ' large analyzed repo over many cycles (with GITNEXUS_POOL_RSS_TRACE=1). Plan U4\n' +
+ ' stays GATED — neither closed nor built on this evidence.',
+ );
+ }
+ console.log(
+ `MACHINE: ${JSON.stringify({ mode: VIA_POOL ? 'via-pool' : 'native', corpus, baseline, firstMed, lastMed, delta, overallSlope: Number(slope.toFixed(3)), firstHalfSlope: Number(firstHalfSlope.toFixed(3)), secondHalfSlope: Number(secondHalfSlope.toFixed(3)), maxJump, stepDiscontinuity, peak, cycles: series.length, verdict: label })}`,
+ );
+ console.log('=====================================================================\n');
+}
+
+// ── main ────────────────────────────────────────────────────────────────────
+(async () => {
+ const result = VIA_POOL ? await runViaPool(VIA_POOL) : await runNative();
+ verdict(result);
+ process.exit(0);
+})().catch((e) => {
+ console.error('[fts-rss] fatal:', e?.stack || e);
+ process.exit(1);
+});
diff --git a/gitnexus/scripts/install-duckdb-extension.mjs b/gitnexus/scripts/install-duckdb-extension.mjs
index 2bc65a05e..7492e084f 100644
--- a/gitnexus/scripts/install-duckdb-extension.mjs
+++ b/gitnexus/scripts/install-duckdb-extension.mjs
@@ -14,7 +14,7 @@ function parseLbugMaxDbSize(raw) {
return Math.floor(parsed);
}
-async function installDuckDbExtension(extensionName) {
+async function installDuckDbExtension(extensionName, verifyOnly = false) {
if (!extensionName || !EXTENSION_NAME_PATTERN.test(extensionName)) {
throw new Error(`Invalid DuckDB extension name: ${extensionName ?? '<missing>'}`);
}
@@ -22,9 +22,11 @@ async function installDuckDbExtension(extensionName) {
const require = createRequire(import.meta.url);
const lbugModule = require('@ladybugdb/core');
const lbug = lbugModule.default ?? lbugModule;
- const lbugMaxDbSize = parseLbugMaxDbSize(
- process.argv[3] ?? process.env.GITNEXUS_LBUG_MAX_DB_SIZE,
- );
+ // argv[3] is the optional positional size; ignore it when it is actually a
+ // flag token (e.g. `--verify-only`) and fall back to the env default.
+ const sizeArg =
+ process.argv[3] && !process.argv[3].startsWith('--') ? process.argv[3] : undefined;
+ const lbugMaxDbSize = parseLbugMaxDbSize(sizeArg ?? process.env.GITNEXUS_LBUG_MAX_DB_SIZE);
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-ext-install-'));
const dbPath = path.join(tmpDir, 'install.lbug');
@@ -34,7 +36,18 @@ async function installDuckDbExtension(extensionName) {
try {
db = new lbug.Database(dbPath, 0, false, false, lbugMaxDbSize);
conn = new lbug.Connection(db);
- await conn.query(`INSTALL ${extensionName}`);
+ if (verifyOnly) {
+ // Prove a previously-baked extension is resolvable by a FRESH process
+ // under the current HOME (the runtime `LOAD EXTENSION` path) — no INSTALL,
+ // no network. Used as a Docker build-time gate so a HOME/extension-dir
+ // mismatch fails the build instead of silently degrading search at runtime.
+ await conn.query(`LOAD EXTENSION ${extensionName}`);
+ console.log(
+ `[install-ext] LOAD-only verify OK for '${extensionName}' (HOME=${process.env.HOME})`,
+ );
+ } else {
+ await conn.query(`INSTALL ${extensionName}`);
+ }
} finally {
if (conn) await conn.close().catch(() => {});
if (db) await db.close().catch(() => {});
@@ -42,7 +55,10 @@ async function installDuckDbExtension(extensionName) {
}
}
-installDuckDbExtension(process.argv[2] ?? process.env.GITNEXUS_LBUG_EXTENSION_NAME).catch((err) => {
+installDuckDbExtension(
+ process.argv[2] ?? process.env.GITNEXUS_LBUG_EXTENSION_NAME,
+ process.argv.includes('--verify-only'),
+).catch((err) => {
console.error(err instanceof Error ? (err.stack ?? err.message) : String(err));
process.exitCode = 1;
});
diff --git a/gitnexus/src/core/lbug/pool-adapter.ts b/gitnexus/src/core/lbug/pool-adapter.ts
index 030688d14..e5338e3d0 100644
--- a/gitnexus/src/core/lbug/pool-adapter.ts
+++ b/gitnexus/src/core/lbug/pool-adapter.ts
@@ -103,6 +103,19 @@ const IDLE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
/** Max connections per repo (caps concurrent queries per repo) */
const MAX_CONNS_PER_REPO = 8;
+// Behavior-neutral RSS tracing for the FTS evict→reload memory repro
+// (gitnexus/scripts/bench/fts-evict-reload-rss.mjs). Two invariants keep it safe
+// in the pool init/close hot path: it writes ONLY to stderr (stdout is the MCP
+// JSON-RPC channel), and the GITNEXUS_POOL_RSS_TRACE gate makes it a no-op — one
+// env-var compare per call, nothing else — unless a harness explicitly enables it.
+function traceRss(event: 'init' | 'close', repoId: string): void {
+ if (process.env.GITNEXUS_POOL_RSS_TRACE !== '1') return;
+ const rssMb = Math.round(process.memoryUsage().rss / (1024 * 1024));
+ process.stderr.write(
+ `[pool-rss] ${event} repo=${repoId} pool=${pool.size} dbCache=${dbCache.size} rssMB=${rssMb}\n`,
+ );
+}
+
let idleTimer: ReturnType<typeof setInterval> | null = null;
// Stdout-capture state lives in `gitnexus/src/mcp/stdio-capture.ts` — a leaf
@@ -240,6 +253,8 @@ function closeOne(repoId: string): void {
// Isolate listener failures — teardown must complete.
}
}
+
+ traceRss('close', repoId);
}
/**
@@ -611,6 +626,7 @@ async function doInitLbug(repoId: string, dbPath: string): Promise<void> {
closed: false,
});
ensureIdleTimer();
+ traceRss('init', repoId);
}
/**
@@ -673,6 +689,7 @@ export async function initLbugWithDb(
closed: false,
});
ensureIdleTimer();
+ traceRss('init', repoId);
}
/**
diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts
index 77411b107..c0a30eb58 100644
--- a/gitnexus/src/mcp/local/local-backend.ts
+++ b/gitnexus/src/mcp/local/local-backend.ts
@@ -1112,73 +1112,120 @@ export class LocalBackend {
>();
const definitions: any[] = []; // standalone symbols not in any process
- for (const [_, item] of merged) {
- const sym = item.data;
- if (!sym.nodeId) {
- // File-level results go to definitions
- definitions.push({
- name: sym.name,
- type: sym.type || 'File',
- filePath: sym.filePath,
- });
- continue;
- }
-
- // Find processes this symbol participates in
- let processRows: any[] = [];
+ // Batch-fetch process participation, cohesion, and (optionally) content for
+ // ALL matched symbols in 2-3 graph queries instead of 2-3 *per symbol*. The
+ // previous per-symbol loop issued up to 3N sequential pool round-trips
+ // (searchLimit symbols × {STEP_IN_PROCESS, MEMBER_OF, content}); on a warm
+ // repo the IPC + query-setup overhead of those round-trips dominated query
+ // latency. Collapsing to `WHERE n.id IN $nodeIds` preserves identical output
+ // (the aggregation loop below is unchanged) while cutting the round-trips.
+ // Array params bind through the pool exactly as bm25Search's
+ // `WHERE n.id IN $nodeIds` already does. (Ported from gitnexus-enterprise
+ // PR #222 — N+1 → 2-3 batched queries.)
+ const nodeIds = merged.map(([, m]) => m.data?.nodeId).filter((id): id is string => !!id);
+
+ const processRowsByNode = new Map<string, any[]>();
+ const cohesionByNode = new Map<string, { cohesion: number; module?: string }>();
+ const contentByNode = new Map<string, string>();
+
+ // Chunk the IN-list like the impact path (CHUNK_SIZE=100) so a large result
+ // set never builds an unbounded `IN` parameter. Default batch is
+ // processLimit*maxSymbolsPerProcess (≤ one chunk), but chunk for robustness.
+ const QUERY_CHUNK_SIZE = 100;
+ for (let i = 0; i < nodeIds.length; i += QUERY_CHUNK_SIZE) {
+ const ids = nodeIds.slice(i, i + QUERY_CHUNK_SIZE);
+
+ // Processes each symbol participates in. `n.id AS nodeId` is prepended as
+ // column 0 so rows from many symbols can be re-associated to their symbol.
try {
- processRows = await executeParameterized(
+ const rows = await executeParameterized(
repo.lbugPath,
`
- MATCH (n {id: $nodeId})-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
- RETURN p.id AS pid, p.label AS label, p.heuristicLabel AS heuristicLabel, p.processType AS processType, p.stepCount AS stepCount, r.step AS step
+ MATCH (n)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
+ WHERE n.id IN $nodeIds
+ RETURN n.id AS nodeId, p.id AS pid, p.label AS label, p.heuristicLabel AS heuristicLabel, p.processType AS processType, p.stepCount AS stepCount, r.step AS step
`,
- { nodeId: sym.nodeId },
+ { nodeIds: ids },
);
+ for (const row of rows) {
+ const nid = row.nodeId ?? row[0];
+ let list = processRowsByNode.get(nid);
+ if (!list) processRowsByNode.set(nid, (list = []));
+ list.push(row);
+ }
} catch (e) {
logQueryError('query:process-lookup', e);
}
- // Get cluster membership + cohesion (cohesion used as internal ranking signal)
- let cohesion = 0;
- let module: string | undefined;
+ // Cluster membership + cohesion. Keep the FIRST community row per node to
+ // mirror the prior per-symbol `LIMIT 1` (each symbol keeps ITS community,
+ // not one community for the whole batch).
try {
- const cohesionRows = await executeParameterized(
+ const rows = await executeParameterized(
repo.lbugPath,
`
- MATCH (n {id: $nodeId})-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
- RETURN c.cohesion AS cohesion, c.heuristicLabel AS module
- LIMIT 1
+ MATCH (n)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
+ WHERE n.id IN $nodeIds
+ RETURN n.id AS nodeId, c.cohesion AS cohesion, c.heuristicLabel AS module
`,
- { nodeId: sym.nodeId },
+ { nodeIds: ids },
);
- if (cohesionRows.length > 0) {
- cohesion = (cohesionRows[0].cohesion ?? cohesionRows[0][0]) || 0;
- module = cohesionRows[0].module ?? cohesionRows[0][1];
+ for (const row of rows) {
+ const nid = row.nodeId ?? row[0];
+ if (!cohesionByNode.has(nid)) {
+ cohesionByNode.set(nid, {
+ cohesion: (row.cohesion ?? row[1]) || 0,
+ module: row.module ?? row[2],
+ });
+ }
}
} catch (e) {
logQueryError('query:cluster-info', e);
}
- // Optionally fetch content
- let content: string | undefined;
+ // Optionally fetch content for every matched symbol.
if (includeContent) {
try {
- const contentRows = await executeParameterized(
+ const rows = await executeParameterized(
repo.lbugPath,
`
- MATCH (n {id: $nodeId})
- RETURN n.content AS content
+ MATCH (n)
+ WHERE n.id IN $nodeIds
+ RETURN n.id AS nodeId, n.content AS content
`,
- { nodeId: sym.nodeId },
+ { nodeIds: ids },
);
- if (contentRows.length > 0) {
- content = contentRows[0].content ?? contentRows[0][0];
+ for (const row of rows) {
+ const nid = row.nodeId ?? row[0];
+ contentByNode.set(nid, row.content ?? row[1]);
}
} catch (e) {
logQueryError('query:content-fetch', e);
}
}
+ }
+
+ // Aggregation is unchanged from the per-symbol version — it now reads the
+ // pre-fetched maps instead of issuing a query per symbol. Iterating `merged`
+ // in the same (sorted) order preserves processMap insertion order, the
+ // definitions order, and the item.score association exactly.
+ for (const [_, item] of merged) {
+ const sym = item.data;
+ if (!sym.nodeId) {
+ // File-level results go to definitions
+ definitions.push({
+ name: sym.name,
+ type: sym.type || 'File',
+ filePath: sym.filePath,
+ });
+ continue;
+ }
+
+ const processRows = processRowsByNode.get(sym.nodeId) ?? [];
+ const coh = cohesionByNode.get(sym.nodeId);
+ const cohesion = coh?.cohesion ?? 0;
+ const module = coh?.module;
+ const content = includeContent ? contentByNode.get(sym.nodeId) : undefined;
const symbolEntry = {
id: sym.nodeId,
@@ -1197,12 +1244,13 @@ export class LocalBackend {
} else {
// Add to each process it belongs to
for (const row of processRows) {
- const pid = row.pid ?? row[0];
- const label = row.label ?? row[1];
- const hLabel = row.heuristicLabel ?? row[2];
- const pType = row.processType ?? row[3];
- const stepCount = row.stepCount ?? row[4];
- const step = row.step ?? row[5];
+ // Positional fallbacks shift +1 because `n.id AS nodeId` is column 0.
+ const pid = row.pid ?? row[1];
+ const label = row.label ?? row[2];
+ const hLabel = row.heuristicLabel ?? row[3];
+ const pType = row.processType ?? row[4];
+ const stepCount = row.stepCount ?? row[5];
+ const step = row.step ?? row[6];
if (!processMap.has(pid)) {
processMap.set(pid, {
diff --git a/gitnexus/test/fixtures/local-backend-seed.ts b/gitnexus/test/fixtures/local-backend-seed.ts
index 3f299046e..4878348b2 100644
--- a/gitnexus/test/fixtures/local-backend-seed.ts
+++ b/gitnexus/test/fixtures/local-backend-seed.ts
@@ -35,6 +35,12 @@ export const LOCAL_BACKEND_SEED_DATA = [
CREATE (a)-[:CodeRelation {type: 'STEP_IN_PROCESS', confidence: 1.0, reason: '', step: 1}]->(p)`,
`MATCH (a:Function), (p:Process) WHERE a.id = 'func:validate' AND p.id = 'proc:login-flow'
CREATE (a)-[:CodeRelation {type: 'STEP_IN_PROCESS', confidence: 1.0, reason: '', step: 2}]->(p)`,
+ // func:validate is the terminalId of proc:beta-flow too — wiring its second
+ // STEP_IN_PROCESS edge makes it a genuine MULTI-process symbol, which the
+ // batched-query test uses to exercise the full row[1..6] positional shift
+ // (a single-process symbol can't expose an off-by-one in those fallbacks).
+ `MATCH (a:Function), (p:Process) WHERE a.id = 'func:validate' AND p.id = 'proc:beta-flow'
+ CREATE (a)-[:CodeRelation {type: 'STEP_IN_PROCESS', confidence: 1.0, reason: '', step: 3}]->(p)`,
`MATCH (h:Function), (t:Tool) WHERE h.id = 'func:alpha' AND t.id = 'Tool:alpha'
CREATE (h)-[:CodeRelation {type: 'HANDLES_TOOL', confidence: 1.0, reason: 'tool-definition', step: 0}]->(t)`,
`MATCH (h:Function), (t:Tool) WHERE h.id = 'func:beta' AND t.id = 'Tool:beta'
diff --git a/gitnexus/test/integration/local-backend-calltool.test.ts b/gitnexus/test/integration/local-backend-calltool.test.ts
index e2640deab..176e061b2 100644
--- a/gitnexus/test/integration/local-backend-calltool.test.ts
+++ b/gitnexus/test/integration/local-backend-calltool.test.ts
@@ -113,6 +113,72 @@ withTestLbugDB(
expect(result.timing.bm25 ?? result.timing.vector).toBeGreaterThanOrEqual(0);
});
+ // PR #222 port: the query tool batches per-symbol process/cohesion/content
+ // lookups (N+1 → 2-3 `WHERE n.id IN $nodeIds` queries). These assertions
+ // guard the batch-adaptation hazards that a naive cherry-pick would break:
+ // (1) each symbol keeps ITS OWN community (the per-node first-row pick that
+ // replaced the per-symbol `LIMIT 1`), and (2) content maps to the right
+ // node — both depend on the +1 positional-index shift after prepending
+ // `n.id AS nodeId`. func:login is MEMBER_OF comm:auth ("Authentication");
+ // func:validate has no community, so it must NOT inherit login's.
+ it('query batches per-symbol enrichment without cross-assigning community/content', async () => {
+ const findSym = (res: any, id: string) =>
+ (res.process_symbols ?? []).find((s: any) => s.id === id) ??
+ (res.definitions ?? []).find((s: any) => s.id === id);
+
+ const loginRes = await backend.callTool('query', {
+ query: 'login',
+ include_content: true,
+ });
+ expect(loginRes).not.toHaveProperty('error');
+ const login = findSym(loginRes, 'func:login');
+ expect(login).toBeDefined();
+ // Community correctly associated to its own node (not dropped, not leaked).
+ expect(login.module).toBe('Authentication');
+ // Content correctly mapped to its own node (positional [1] after nodeId).
+ expect(login.content).toBe('function login() {}');
+
+ const validateRes = await backend.callTool('query', {
+ query: 'validate',
+ include_content: true,
+ });
+ expect(validateRes).not.toHaveProperty('error');
+ const validate = findSym(validateRes, 'func:validate');
+ expect(validate).toBeDefined();
+ // validate has no MEMBER_OF edge — a flat batched `LIMIT 1` would have
+ // leaked some other node's community onto it. It must have none.
+ expect(validate.module).toBeUndefined();
+ expect(validate.content).toBe('function validate() {}');
+ });
+
+ // PR #222 port: a symbol in MULTIPLE processes is what fully exercises the
+ // +1 positional shift in the batched STEP_IN_PROCESS aggregation — with a
+ // single process row, `row.pid ?? row[1]` succeeds whether the shift is
+ // right or wrong. func:validate is a step in BOTH proc:login-flow (step 2)
+ // and proc:beta-flow (step 3), so both rows for the one node must be parsed
+ // (pid=row[1], step=row[6]); an off-by-one would drop a process or mis-pair
+ // pid↔step. Also pins process ranking (totalScore via the regroup-by-nodeId).
+ it('query batches a multi-process symbol and ranks processes (positional shift across rows)', async () => {
+ const res = await backend.callTool('query', { query: 'validate' });
+ expect(res).not.toHaveProperty('error');
+ const processIds = (res.processes ?? []).map((p: any) => p.id);
+ // Both of validate's processes must appear — both STEP_IN_PROCESS rows
+ // were parsed and grouped by the correct pid (row[1]).
+ expect(processIds).toContain('proc:login-flow');
+ expect(processIds).toContain('proc:beta-flow');
+
+ // process_symbols dedups by id, so validate appears once carrying the
+ // pid+step of its top-ranked process — they must come from the SAME
+ // shifted row: login-flow⇒step 2, beta-flow⇒step 3.
+ const v = (res.process_symbols ?? []).find((s: any) => s.id === 'func:validate');
+ expect(v).toBeDefined();
+ expect(v.step_index).toBe(v.process_id === 'proc:beta-flow' ? 3 : 2);
+
+ // Ranking: 'login' surfaces proc:login-flow as the top process.
+ const loginRes = await backend.callTool('query', { query: 'login' });
+ expect((loginRes.processes ?? [])[0]?.id).toBe('proc:login-flow');
+ });
+
it('tool_map returns per-tool flows without cross-attributing same-file tools', async () => {
const result = await backend.callTool('tool_map', {});
expect(result).not.toHaveProperty('error');

View file

@ -0,0 +1,453 @@
diff --git a/gitnexus/bench/impact-pdg/README.md b/gitnexus/bench/impact-pdg/README.md
index 845debfa2..a7bbac3ce 100644
--- a/gitnexus/bench/impact-pdg/README.md
+++ b/gitnexus/bench/impact-pdg/README.md
@@ -227,10 +227,14 @@ analyze via a temp `GITNEXUS_HOME`, mock-free**. Per fixture:
first, keeping the source tree clean).
2. **Shell out** to the real CLI as a child process — child-process isolation
sidesteps `process.exit`; real `saveMeta` + `registerRepo` land in the temp
- home; parse workers spawn from `dist/` (so the harness needs a built `dist/`):
+ home. The harness prefers the built `dist/` CLI (plain JS, no tsx; the parse
+ workers it spawns also load from `dist/`), so it needs a built `dist/`; it
+ falls back to tsx's own CLI over `src/` for build-free local runs. (`node
+ --import tsx src/cli/index.ts` is avoided: Node ≥22.18 native type-stripping
+ breaks the `.ts` entry's `./lazy-action.js`→`.ts` import resolution.)
```
- node --import tsx src/cli/index.ts analyze <fixtureCopy> --pdg --skip-git --index-only
+ node dist/cli/index.js analyze <fixtureCopy> --pdg --skip-git --index-only
```
3. `new LocalBackend(); await init()` resolves the fixture via the **real**
registry (the parent process sets `GITNEXUS_HOME` too, so `init()` reads the
diff --git a/gitnexus/bench/impact-pdg/gate-mutation-recall.mjs b/gitnexus/bench/impact-pdg/gate-mutation-recall.mjs
index 5d3e44a0d..2ab46072a 100644
--- a/gitnexus/bench/impact-pdg/gate-mutation-recall.mjs
+++ b/gitnexus/bench/impact-pdg/gate-mutation-recall.mjs
@@ -17,7 +17,14 @@ const floor = Number(process.env.MUTATION_RECALL_FLOOR ?? '0.5');
const report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
const checks = Array.isArray(report?.mutation?.checks) ? report.mutation.checks : [];
-const scored = checks.filter((c) => typeof c.recall === 'number');
+// Gate only the checks the oracle marked recall-gated. measure.mjs sets
+// `recallGated: false` for cases a forward value-diff oracle cannot fairly
+// score against the PDG slice: UPSTREAM fixtures (the oracle runs in its native
+// downstream sense, so its behavioral AIS can never intersect a reverse slice —
+// recall is 0 by construction) and id-discrimination corroboration fixtures.
+// Those still carry a numeric `recall` for the report, so the legacy
+// `typeof c.recall === 'number'` filter wrongly tripped the floor on them.
+const scored = checks.filter((c) => c.recallGated === true && typeof c.recall === 'number');
const recalls = scored.map((c) => c.recall);
const min = recalls.length ? Math.min(...recalls) : null;
const mean = recalls.length ? recalls.reduce((a, b) => a + b, 0) / recalls.length : null;
@@ -39,6 +46,17 @@ if (process.env.GITHUB_STEP_SUMMARY) {
}
process.stdout.write(summary + '\n');
+// A report that produced checks but gated NONE of them has no recall signal:
+// the floor check below would pass vacuously (`min === null`). Fail loudly so a
+// degenerate corpus, or a harvest that silently emptied every behavioral AIS,
+// surfaces as a red run instead of a green "scored cases: 0 of N".
+if (checks.length > 0 && scored.length === 0) {
+ console.error(
+ `Mutation gate has no signal: 0 of ${checks.length} checks were recall-gated — refusing to pass.`,
+ );
+ process.exit(1);
+}
+
if (min !== null && min < floor) {
console.error(`Mutation recall regression: min realized recall ${fmt(min)} < floor ${floor}`);
process.exit(1);
diff --git a/gitnexus/bench/impact-pdg/measure.mjs b/gitnexus/bench/impact-pdg/measure.mjs
index 65d3a7675..f6158f1da 100644
--- a/gitnexus/bench/impact-pdg/measure.mjs
+++ b/gitnexus/bench/impact-pdg/measure.mjs
@@ -25,11 +25,16 @@
* `repo-manager.getGlobalDir()` — it roots the registry; the per-repo DB
* lands in `<fixtureCopy>/.gitnexus/`, so fixtures are copied to a temp
* working dir to keep the source tree clean);
- * 2. SHELL OUT to the real CLI as a child process:
- * node --import tsx src/cli/index.ts analyze <copy> --pdg --skip-git --index-only
- * (child-process isolation sidesteps `process.exit`; real `saveMeta` +
- * `registerRepo` land in the temp home; workers spawn from `dist/`, so the
- * harness builds `dist/` first — run `node scripts/build.js`);
+ * 2. SHELL OUT to the real CLI as a child process (see `cliChildArgs`):
+ * node dist/cli/index.js analyze <copy> --pdg --skip-git --index-only
+ * preferring the BUILT `dist/` CLI when present — plain JS, no tsx, and the
+ * parse workers it spawns also load from `dist/`. The mutation workflow
+ * builds `dist/` first (`node scripts/build.js`); `node --import tsx
+ * src/cli/index.ts` is NOT used because Node >=22.18 native type-stripping
+ * breaks the `.js`->`.ts` entry resolution (ERR_MODULE_NOT_FOUND on
+ * `lazy-action.js`). Build-free runs fall back to tsx's own CLI over src.
+ * (Child-process isolation sidesteps `process.exit`; real `saveMeta` +
+ * `registerRepo` land in the temp home);
* 3. `new LocalBackend(); await init()` resolves the fixture via the REAL
* registry (the parent process ALSO sets `GITNEXUS_HOME` so init reads the
* temp registry, not the user's ~/.gitnexus);
@@ -53,6 +58,7 @@ import os from 'node:os';
import path from 'node:path';
import crypto from 'node:crypto';
import { spawnSync } from 'node:child_process';
+import { createRequire } from 'node:module';
import { fileURLToPath, pathToFileURL } from 'node:url';
import {
@@ -95,6 +101,33 @@ const REPO_ROOT = path.resolve(__dirname, '..', '..'); // gitnexus/
const FIXTURES_DIR = path.join(__dirname, 'fixtures');
const BASELINE_PATH = path.join(__dirname, 'baselines.json');
const CLI_ENTRY = path.join(REPO_ROOT, 'src', 'cli', 'index.ts');
+// Shipped CLI entry (package.json `bin`). PREFERRED for the child analyze: it's
+// plain compiled JS, so the analyze process — AND the parse workers it spawns,
+// which resolve relative to the running entry — load from `dist/` with no tsx in
+// the loop. The build-free path below stays as a fallback.
+const DIST_CLI = path.join(REPO_ROOT, 'dist', 'cli', 'index.js');
+// Build-free fallback: tsx's OWN cli entry (resolved from this package), NOT
+// `node --import tsx <entry>.ts`. On Node >=22.18 native TypeScript type-
+// stripping is enabled by default and intercepts the `.ts` entry before tsx's
+// `--import` resolve hook applies; native stripping does NOT remap `./foo.js`
+// specifiers to `foo.ts` (tsx does), so `node --import tsx src/cli/index.ts`
+// crashes resolving `./lazy-action.js` (ERR_MODULE_NOT_FOUND) on newer Node.
+// The tsx CLI takes over module loading and is version-agnostic across the
+// declared engines range (node >=22.0, where `--no-experimental-strip-types`
+// is not a universally-recognized flag). Workers still spawn from src via tsx on
+// this path, so it is only robust on the older Node devs run locally.
+const TSX_CLI = createRequire(import.meta.url).resolve('tsx/cli');
+
+/**
+ * Build the argv that runs the real CLI as a child of `process.execPath`.
+ * Prefers the built `dist/` CLI (production-faithful, no tsx, dist workers) when
+ * present — this is what the mutation workflow uses (it builds dist first). Falls
+ * back to the tsx CLI over src for build-free local runs. Returns the args AFTER
+ * the node binary, i.e. ready for `spawnSync(process.execPath, [...args])`.
+ */
+function cliChildArgs(rest) {
+ return fs.existsSync(DIST_CLI) ? [DIST_CLI, ...rest] : [TSX_CLI, CLI_ENTRY, ...rest];
+}
const SCOPES = ['intra', 'inter', 'mixed'];
const MODES = ['callgraph', 'pdg'];
@@ -138,7 +171,7 @@ async function analyzeAndImpact(fx, home, { pdgOn = true } = {}) {
fs.cpSync(path.join(fx.dir, 'src'), path.join(work, 'src'), { recursive: true });
const env = { ...process.env, GITNEXUS_HOME: home };
- const args = ['--import', 'tsx', CLI_ENTRY, 'analyze', work, '--skip-git', '--index-only'];
+ const args = cliChildArgs(['analyze', work, '--skip-git', '--index-only']);
if (pdgOn) args.push('--pdg');
const an = spawnSync(process.execPath, args, {
env,
diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json
index b28f005d7..927fe93c3 100644
--- a/gitnexus/package-lock.json
+++ b/gitnexus/package-lock.json
@@ -52,6 +52,10 @@
"gitnexus": "dist/cli/index.js"
},
"devDependencies": {
+ "@babel/generator": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7",
"@types/busboy": "^1.5.4",
"@types/cli-progress": "^3.11.6",
"@types/cors": "^2.8.17",
@@ -76,10 +80,59 @@
"typescript": "^6.0.3"
}
},
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/code-frame/node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
+ "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
+ "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/helper-string-parser": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
- "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -87,9 +140,9 @@
}
},
"node_modules/@babel/helper-validator-identifier": {
- "version": "7.28.5",
- "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
- "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -97,13 +150,13 @@
}
},
"node_modules/@babel/parser": {
- "version": "7.29.2",
- "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
- "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
+ "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/types": "^7.29.0"
+ "@babel/types": "^7.29.7"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -112,15 +165,49 @@
"node": ">=6.0.0"
}
},
+ "node_modules/@babel/template": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
+ "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
+ "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-globals": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/types": {
- "version": "7.29.0",
- "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
- "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
+ "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-string-parser": "^7.27.1",
- "@babel/helper-validator-identifier": "^7.28.5"
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
@@ -1128,6 +1215,17 @@
"node": ">=18.0.0"
}
},
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
@@ -1471,9 +1569,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1491,9 +1586,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1511,9 +1603,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1531,9 +1620,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1551,9 +1637,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1571,9 +1654,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3487,6 +3567,19 @@
"js-yaml": "bin/js-yaml.js"
}
},
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/json-bignum": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/json-bignum/-/json-bignum-0.0.3.tgz",
@@ -3668,9 +3761,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3692,9 +3782,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3716,9 +3803,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3740,9 +3824,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
diff --git a/gitnexus/package.json b/gitnexus/package.json
index 6fa0c9007..82f2cabfd 100644
--- a/gitnexus/package.json
+++ b/gitnexus/package.json
@@ -94,6 +94,10 @@
"uuid": "^14.0.0"
},
"devDependencies": {
+ "@babel/generator": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7",
"@types/busboy": "^1.5.4",
"@types/cli-progress": "^3.11.6",
"@types/cors": "^2.8.17",

View file

@ -0,0 +1,414 @@
diff --git a/gitnexus/bench/impact-pdg/gate-mutation-recall.mjs b/gitnexus/bench/impact-pdg/gate-mutation-recall.mjs
index 5d3e44a0d..49953f8ab 100644
--- a/gitnexus/bench/impact-pdg/gate-mutation-recall.mjs
+++ b/gitnexus/bench/impact-pdg/gate-mutation-recall.mjs
@@ -17,7 +17,14 @@ const floor = Number(process.env.MUTATION_RECALL_FLOOR ?? '0.5');
const report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
const checks = Array.isArray(report?.mutation?.checks) ? report.mutation.checks : [];
-const scored = checks.filter((c) => typeof c.recall === 'number');
+// Gate only the checks the oracle marked recall-gated. measure.mjs sets
+// `recallGated: false` for cases a forward value-diff oracle cannot fairly
+// score against the PDG slice: UPSTREAM fixtures (the oracle runs in its native
+// downstream sense, so its behavioral AIS can never intersect a reverse slice —
+// recall is 0 by construction) and id-discrimination corroboration fixtures.
+// Those still carry a numeric `recall` for the report, so the legacy
+// `typeof c.recall === 'number'` filter wrongly tripped the floor on them.
+const scored = checks.filter((c) => c.recallGated === true && typeof c.recall === 'number');
const recalls = scored.map((c) => c.recall);
const min = recalls.length ? Math.min(...recalls) : null;
const mean = recalls.length ? recalls.reduce((a, b) => a + b, 0) / recalls.length : null;
diff --git a/gitnexus/bench/impact-pdg/measure.mjs b/gitnexus/bench/impact-pdg/measure.mjs
index 65d3a7675..f6158f1da 100644
--- a/gitnexus/bench/impact-pdg/measure.mjs
+++ b/gitnexus/bench/impact-pdg/measure.mjs
@@ -25,11 +25,16 @@
* `repo-manager.getGlobalDir()` — it roots the registry; the per-repo DB
* lands in `<fixtureCopy>/.gitnexus/`, so fixtures are copied to a temp
* working dir to keep the source tree clean);
- * 2. SHELL OUT to the real CLI as a child process:
- * node --import tsx src/cli/index.ts analyze <copy> --pdg --skip-git --index-only
- * (child-process isolation sidesteps `process.exit`; real `saveMeta` +
- * `registerRepo` land in the temp home; workers spawn from `dist/`, so the
- * harness builds `dist/` first — run `node scripts/build.js`);
+ * 2. SHELL OUT to the real CLI as a child process (see `cliChildArgs`):
+ * node dist/cli/index.js analyze <copy> --pdg --skip-git --index-only
+ * preferring the BUILT `dist/` CLI when present — plain JS, no tsx, and the
+ * parse workers it spawns also load from `dist/`. The mutation workflow
+ * builds `dist/` first (`node scripts/build.js`); `node --import tsx
+ * src/cli/index.ts` is NOT used because Node >=22.18 native type-stripping
+ * breaks the `.js`->`.ts` entry resolution (ERR_MODULE_NOT_FOUND on
+ * `lazy-action.js`). Build-free runs fall back to tsx's own CLI over src.
+ * (Child-process isolation sidesteps `process.exit`; real `saveMeta` +
+ * `registerRepo` land in the temp home);
* 3. `new LocalBackend(); await init()` resolves the fixture via the REAL
* registry (the parent process ALSO sets `GITNEXUS_HOME` so init reads the
* temp registry, not the user's ~/.gitnexus);
@@ -53,6 +58,7 @@ import os from 'node:os';
import path from 'node:path';
import crypto from 'node:crypto';
import { spawnSync } from 'node:child_process';
+import { createRequire } from 'node:module';
import { fileURLToPath, pathToFileURL } from 'node:url';
import {
@@ -95,6 +101,33 @@ const REPO_ROOT = path.resolve(__dirname, '..', '..'); // gitnexus/
const FIXTURES_DIR = path.join(__dirname, 'fixtures');
const BASELINE_PATH = path.join(__dirname, 'baselines.json');
const CLI_ENTRY = path.join(REPO_ROOT, 'src', 'cli', 'index.ts');
+// Shipped CLI entry (package.json `bin`). PREFERRED for the child analyze: it's
+// plain compiled JS, so the analyze process — AND the parse workers it spawns,
+// which resolve relative to the running entry — load from `dist/` with no tsx in
+// the loop. The build-free path below stays as a fallback.
+const DIST_CLI = path.join(REPO_ROOT, 'dist', 'cli', 'index.js');
+// Build-free fallback: tsx's OWN cli entry (resolved from this package), NOT
+// `node --import tsx <entry>.ts`. On Node >=22.18 native TypeScript type-
+// stripping is enabled by default and intercepts the `.ts` entry before tsx's
+// `--import` resolve hook applies; native stripping does NOT remap `./foo.js`
+// specifiers to `foo.ts` (tsx does), so `node --import tsx src/cli/index.ts`
+// crashes resolving `./lazy-action.js` (ERR_MODULE_NOT_FOUND) on newer Node.
+// The tsx CLI takes over module loading and is version-agnostic across the
+// declared engines range (node >=22.0, where `--no-experimental-strip-types`
+// is not a universally-recognized flag). Workers still spawn from src via tsx on
+// this path, so it is only robust on the older Node devs run locally.
+const TSX_CLI = createRequire(import.meta.url).resolve('tsx/cli');
+
+/**
+ * Build the argv that runs the real CLI as a child of `process.execPath`.
+ * Prefers the built `dist/` CLI (production-faithful, no tsx, dist workers) when
+ * present — this is what the mutation workflow uses (it builds dist first). Falls
+ * back to the tsx CLI over src for build-free local runs. Returns the args AFTER
+ * the node binary, i.e. ready for `spawnSync(process.execPath, [...args])`.
+ */
+function cliChildArgs(rest) {
+ return fs.existsSync(DIST_CLI) ? [DIST_CLI, ...rest] : [TSX_CLI, CLI_ENTRY, ...rest];
+}
const SCOPES = ['intra', 'inter', 'mixed'];
const MODES = ['callgraph', 'pdg'];
@@ -138,7 +171,7 @@ async function analyzeAndImpact(fx, home, { pdgOn = true } = {}) {
fs.cpSync(path.join(fx.dir, 'src'), path.join(work, 'src'), { recursive: true });
const env = { ...process.env, GITNEXUS_HOME: home };
- const args = ['--import', 'tsx', CLI_ENTRY, 'analyze', work, '--skip-git', '--index-only'];
+ const args = cliChildArgs(['analyze', work, '--skip-git', '--index-only']);
if (pdgOn) args.push('--pdg');
const an = spawnSync(process.execPath, args, {
env,
diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json
index b28f005d7..927fe93c3 100644
--- a/gitnexus/package-lock.json
+++ b/gitnexus/package-lock.json
@@ -52,6 +52,10 @@
"gitnexus": "dist/cli/index.js"
},
"devDependencies": {
+ "@babel/generator": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7",
"@types/busboy": "^1.5.4",
"@types/cli-progress": "^3.11.6",
"@types/cors": "^2.8.17",
@@ -76,10 +80,59 @@
"typescript": "^6.0.3"
}
},
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/code-frame/node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
+ "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
+ "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/helper-string-parser": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
- "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -87,9 +140,9 @@
}
},
"node_modules/@babel/helper-validator-identifier": {
- "version": "7.28.5",
- "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
- "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -97,13 +150,13 @@
}
},
"node_modules/@babel/parser": {
- "version": "7.29.2",
- "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
- "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
+ "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/types": "^7.29.0"
+ "@babel/types": "^7.29.7"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -112,15 +165,49 @@
"node": ">=6.0.0"
}
},
+ "node_modules/@babel/template": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
+ "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
+ "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-globals": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/types": {
- "version": "7.29.0",
- "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
- "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
+ "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-string-parser": "^7.27.1",
- "@babel/helper-validator-identifier": "^7.28.5"
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
@@ -1128,6 +1215,17 @@
"node": ">=18.0.0"
}
},
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
@@ -1471,9 +1569,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1491,9 +1586,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1511,9 +1603,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1531,9 +1620,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1551,9 +1637,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1571,9 +1654,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3487,6 +3567,19 @@
"js-yaml": "bin/js-yaml.js"
}
},
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/json-bignum": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/json-bignum/-/json-bignum-0.0.3.tgz",
@@ -3668,9 +3761,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3692,9 +3782,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3716,9 +3803,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3740,9 +3824,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
diff --git a/gitnexus/package.json b/gitnexus/package.json
index 6fa0c9007..82f2cabfd 100644
--- a/gitnexus/package.json
+++ b/gitnexus/package.json
@@ -94,6 +94,10 @@
"uuid": "^14.0.0"
},
"devDependencies": {
+ "@babel/generator": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7",
"@types/busboy": "^1.5.4",
"@types/cli-progress": "^3.11.6",
"@types/cors": "^2.8.17",

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,817 @@
diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml
index b4fbb3d56..6d03b4c73 100644
--- a/.github/workflows/ci-tests.yml
+++ b/.github/workflows/ci-tests.yml
@@ -500,6 +500,17 @@ jobs:
run: node --import tsx bench/callable-value-flow/measure.mjs --check
working-directory: gitnexus
+ - name: C++ qualified-namespace resolution guards (#2788)
+ # Build-free: asserts resolveCppQualifiedNamespaceMember resolves an
+ # unchanged symbol set (fingerprint) and that per-call-site cost stays
+ # independent of corpus size. It rescanned every parsed file per
+ # qualified `ns::member()` call site until #2788 — 25 of 33 analyze
+ # minutes on a 1.5k-file C++ repo. #1990 had already fixed the exact
+ # same bug in the sibling ADL path and shipped without a scaling gate,
+ # which is how the bug class came back; this step is that gate.
+ run: node --import tsx bench/cpp-qualified-ns/measure.mjs --check
+ working-directory: gitnexus
+
- name: Receiver-resolution drop guards
# NOT build-free: this one runs the real pipeline, so it needs dist/
# (the setup action above builds). ~2m15s.
diff --git a/gitnexus/bench/cpp-qualified-ns/baselines.json b/gitnexus/bench/cpp-qualified-ns/baselines.json
new file mode 100644
index 000000000..01d1cad17
--- /dev/null
+++ b/gitnexus/bench/cpp-qualified-ns/baselines.json
@@ -0,0 +1,6 @@
+{
+ "_comment": "Baselines for bench/cpp-qualified-ns/measure.mjs --check (#2788). `fingerprint` is a sha256 over every `receiver::member -> outcome` the synthetic corpus resolves at the LARGE scale (hit nodeId, `<ambiguous>` per #1564, or `<none>`); it is a CORRECTNESS gate, so drift means C++ qualified `ns::member()` lookup started resolving a different symbol set and must be explained, never re-baselined to make CI green. `scaling_budget` is a timing gate and carries deliberate headroom for shared CI runners.",
+ "fingerprint": "30122b086b90fe1d56edc5acfd39ab7f26c00350f0e672123aaf607c7702e1e2",
+ "scaling_budget": 1.8,
+ "_scaling_note": "(t_large/t_small)/(1600/400). ~1.0 is linear; measured 0.93-1.21 on the indexed implementation. The pre-#2788 per-call-site workspace rescan measured 3.45 on the same corpus shape (at a reduced 100/400 scale, since 1600 files x 32k call sites of quadratic work does not finish in a CI step) — so the budget sits between the two bands and cannot be met by reintroducing the scan. Resolution is timed alone; the fingerprint's outcome strings are built in a separate untimed pass because their allocation cost grows with the corpus and would otherwise show up as scaling."
+}
diff --git a/gitnexus/bench/cpp-qualified-ns/measure.mjs b/gitnexus/bench/cpp-qualified-ns/measure.mjs
new file mode 100644
index 000000000..36da04b7a
--- /dev/null
+++ b/gitnexus/bench/cpp-qualified-ns/measure.mjs
@@ -0,0 +1,258 @@
+/**
+ * Build-free scaling + identity bench for `resolveCppQualifiedNamespaceMember`,
+ * the C++ qualified `ns::member()` receiver resolver (issue #2788).
+ *
+ * Before #2788 this function re-scanned EVERY parsed file — rebuilding a
+ * per-file `scopesById` map each time — once per qualified call site, so the
+ * scope-resolution emit phase cost O(callsites × scopes). On a 1,473-file C++
+ * repo that was 25.3 min of a 33-min analyze, with 75% of total self-time in
+ * this one function. It is the same bug #1990 had already fixed in the sibling
+ * ADL path (`pickCppAdlCandidates` → `AdlCandidateIndex`) — the sibling shipped
+ * without a scaling gate, and the bug class came straight back here. Hence this
+ * bench: a per-call-site workspace scan must not be reintroduced silently.
+ *
+ * For a synthetic corpus at two scales it reports:
+ * - `elapsed_ms` per scale (fastest of REPS, see `fastest`) for resolving
+ * every call site once, INCLUDING the one-time index build — that build is
+ * the work the per-site scan was traded for, so hiding it would let an
+ * index that is itself quadratic pass;
+ * - a scaling ratio `(t_large/t_small)/(LARGE/SMALL)`: ~1.0 linear,
+ * ~4.x quadratic at this scale gap;
+ * - a sha256 fingerprint over every `receiver::member → outcome` the corpus
+ * resolves, as the correctness gate. A fingerprint change means qualified
+ * lookup started resolving different symbols — a behaviour change, never a
+ * performance one.
+ *
+ * Build-free: imports the `.ts` hotpath through tsx
+ * (`node --import tsx bench/cpp-qualified-ns/measure.mjs`).
+ *
+ * Without args: prints the JSON report.
+ * With `--check`: asserts the fingerprint == the committed baseline AND the
+ * scaling ratio is within budget; exits non-zero on drift/regression.
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+import crypto from 'node:crypto';
+import { fileURLToPath } from 'node:url';
+
+import {
+ clearCppInlineNamespaces,
+ markCppInlineNamespaceRange,
+ populateCppInlineNamespaceScopes,
+ resolveCppQualifiedNamespaceMember,
+} from '../../src/core/ingestion/languages/cpp/inline-namespaces.ts';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const BASELINE_PATH = path.resolve(__dirname, 'baselines.json');
+
+const SMALL = 400;
+const LARGE = 1600;
+const CALLS_PER_FILE = 20;
+const REPS = 7;
+const WARMUP = 3;
+
+const NO_SCOPES = {};
+
+/**
+ * Deterministic synthetic corpus — no randomness, so the fingerprint is stable.
+ *
+ * Per file, one `ns_f` namespace shaped like the ABI-versioning idiom the
+ * reporter's repo uses (`namespace x { inline namespace v { … } }`):
+ *
+ * namespace ns_f {
+ * void own0(); void own1(); // direct members
+ * inline namespace v1 { void inl0(); void dup(); } // transitively visible
+ * inline namespace v2 { void dup(); } // → dup is ambiguous
+ * namespace detail { void hidden0(); } // NOT inline → invisible
+ * }
+ *
+ * The three outcome classes all appear, because each takes a different exit
+ * from the resolver and only exercising the hit path would let a regression in
+ * the miss path (the most common outcome in real source) go unmeasured:
+ * resolved hit, `'ambiguous'` (#1564), and `undefined` (miss — both a wrong
+ * member name and a non-inline nested member).
+ */
+function buildCorpus(fileCount) {
+ const parsedFiles = [];
+ for (let f = 0; f < fileCount; f++) {
+ const filePath = `src/file${f}.cpp`;
+ const scopes = [];
+ let line = 1;
+ const scope = (id, kind, parent, defs) => {
+ const entry = {
+ id,
+ kind,
+ parent,
+ ownedDefs: defs,
+ range: { startLine: line, startCol: 0, endLine: line + 1, endCol: 0 },
+ };
+ line += 2;
+ scopes.push(entry);
+ return entry;
+ };
+ const def = (type, qualifiedName) => ({
+ nodeId: `def:${filePath}#${qualifiedName}`,
+ type,
+ qualifiedName,
+ });
+
+ const nsId = `sc:${f}:ns`;
+ scope(nsId, 'Namespace', null, [
+ def('Namespace', `ns_${f}`),
+ def('Function', `ns_${f}.own0`),
+ def('Function', `ns_${f}.own1`),
+ ]);
+ const v1 = scope(`sc:${f}:v1`, 'Namespace', nsId, [
+ def('Namespace', `ns_${f}.v1`),
+ def('Function', `ns_${f}.v1.inl0`),
+ def('Function', `ns_${f}.v1.dup`),
+ ]);
+ const v2 = scope(`sc:${f}:v2`, 'Namespace', nsId, [
+ def('Namespace', `ns_${f}.v2`),
+ def('Function', `ns_${f}.v2.dup`),
+ ]);
+ scope(`sc:${f}:detail`, 'Namespace', nsId, [
+ def('Namespace', `ns_${f}.detail`),
+ def('Function', `ns_${f}.detail.hidden0`),
+ ]);
+
+ parsedFiles.push({ filePath, scopes, inlineRanges: [v1.range, v2.range] });
+ }
+ return parsedFiles;
+}
+
+/** Capture-time inline marking + `populateOwners`-time scope-id resolution, in
+ * the same order the pipeline runs them. Must re-run after every
+ * `clearCppInlineNamespaces`, which drops both the marks and the index. */
+function populateInlineState(parsedFiles) {
+ clearCppInlineNamespaces();
+ for (const parsed of parsedFiles) {
+ for (const range of parsed.inlineRanges) markCppInlineNamespaceRange(parsed.filePath, range);
+ populateCppInlineNamespaceScopes(parsed);
+ }
+}
+
+/** The call sites: a deterministic spread over receivers and member names so
+ * each rep resolves the same set, with hits, misses and ambiguities mixed. */
+const MEMBERS = ['own0', 'inl0', 'dup', 'hidden0', 'nosuch'];
+function callSites(fileCount) {
+ const sites = [];
+ for (let f = 0; f < fileCount; f++) {
+ for (let c = 0; c < CALLS_PER_FILE; c++) {
+ sites.push([`ns_${(f * 7 + c * 13) % fileCount}`, MEMBERS[c % MEMBERS.length]]);
+ }
+ }
+ return sites;
+}
+
+/** The timed loop: resolution only. The outcome strings the fingerprint needs
+ * are built in a separate untimed pass (`outcomesOf`), so their allocation
+ * cost — which grows with the corpus and would inflate the scaling ratio on
+ * its own — never lands in the measurement. `sink` keeps the calls live. */
+function resolveAll(parsedFiles, sites) {
+ let sink = 0;
+ for (const [receiver, member] of sites) {
+ const hit = resolveCppQualifiedNamespaceMember(receiver, member, parsedFiles, NO_SCOPES);
+ if (hit !== undefined) sink++;
+ }
+ return sink;
+}
+
+function outcomesOf(parsedFiles, sites) {
+ const outcomes = [];
+ for (const [receiver, member] of sites) {
+ const hit = resolveCppQualifiedNamespaceMember(receiver, member, parsedFiles, NO_SCOPES);
+ outcomes.push(
+ `${receiver}::${member}\u0000${hit === undefined ? '<none>' : hit === 'ambiguous' ? '<ambiguous>' : hit.nodeId}`,
+ );
+ }
+ return outcomes;
+}
+
+/**
+ * MIN, not median — same rationale as bench/callable-value-flow: both scales
+ * are timed in one process and every error source (scheduler preemption, GC, a
+ * noisy neighbour on a shared CI runner) is additive, so the fastest observed
+ * run is the closest estimate of the uncontended cost and keeps the derived
+ * ratio comparable across machines.
+ */
+function fastest(values) {
+ return Math.min(...values);
+}
+
+/** Time one full pass: index build (lazy, on the first call) + every call
+ * site. The corpus state is reset OUTSIDE the timer so the reset's own
+ * O(files) cost never lands in the measurement. */
+function timeResolution(parsedFiles, sites) {
+ for (let w = 0; w < WARMUP; w++) {
+ populateInlineState(parsedFiles);
+ resolveAll(parsedFiles, sites);
+ }
+ const samples = [];
+ for (let r = 0; r < REPS; r++) {
+ populateInlineState(parsedFiles);
+ const t0 = performance.now();
+ resolveAll(parsedFiles, sites);
+ samples.push(performance.now() - t0);
+ }
+ return { ms: fastest(samples), outcomes: outcomesOf(parsedFiles, sites) };
+}
+
+function fingerprint(outcomes) {
+ return crypto
+ .createHash('sha256')
+ .update([...outcomes].sort().join('\n'))
+ .digest('hex');
+}
+
+const scales = {};
+for (const [name, fileCount] of [
+ ['small', SMALL],
+ ['large', LARGE],
+]) {
+ const parsedFiles = buildCorpus(fileCount);
+ const sites = callSites(fileCount);
+ const { ms, outcomes } = timeResolution(parsedFiles, sites);
+ scales[name] = {
+ files: fileCount,
+ call_sites: sites.length,
+ ms: Number(ms.toFixed(3)),
+ fingerprint: fingerprint(outcomes),
+ };
+}
+
+const scalingRatio = scales.large.ms / scales.small.ms / (LARGE / SMALL);
+
+const report = {
+ small: scales.small,
+ large: scales.large,
+ scaling_ratio: Number(scalingRatio.toFixed(3)),
+ fingerprint: scales.large.fingerprint,
+};
+
+if (!process.argv.includes('--check')) {
+ console.log(JSON.stringify(report, null, 2));
+ process.exit(0);
+}
+
+const baseline = JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf-8'));
+const failures = [];
+if (report.fingerprint !== baseline.fingerprint) {
+ failures.push(
+ `fingerprint drift: ${report.fingerprint} != ${baseline.fingerprint} — qualified ` +
+ `namespace lookup resolved a DIFFERENT symbol set. This is a behaviour change, not a perf one.`,
+ );
+}
+if (report.scaling_ratio > baseline.scaling_budget) {
+ failures.push(
+ `scaling ${report.scaling_ratio} > budget ${baseline.scaling_budget} — per-call-site cost ` +
+ `now grows with corpus size again (#2788).`,
+ );
+}
+
+console.log(JSON.stringify(report, null, 2));
+if (failures.length > 0) {
+ console.error(`[cpp-qualified-ns --check] FAIL\n - ${failures.join('\n - ')}`);
+ process.exit(1);
+}
+console.log('[cpp-qualified-ns --check] PASS');
diff --git a/gitnexus/src/core/ingestion/languages/cpp/inline-namespaces.ts b/gitnexus/src/core/ingestion/languages/cpp/inline-namespaces.ts
index 134738dfd..ad2749928 100644
--- a/gitnexus/src/core/ingestion/languages/cpp/inline-namespaces.ts
+++ b/gitnexus/src/core/ingestion/languages/cpp/inline-namespaces.ts
@@ -14,12 +14,14 @@
* 2. **Transitive qualified visibility.** `outer::foo()` resolves to
* `outer::v1::foo()` when `v1` is inline. The qualified-namespace
* receiver resolver (`resolveCppQualifiedNamespaceMember`) walks
- * inline-namespace children transitively when collecting candidates.
+ * inline-namespace children transitively when collecting candidates —
+ * once per pipeline run, into {@link QualifiedNsMemberIndex} (#2788).
*
* State lifecycle: capture-time `markCppInlineNamespaceRange` records each
* inline namespace's source range; `populateCppInlineNamespaceScopes`
* resolves ranges to `ScopeId`s during `populateOwners`. Cleared via
- * `clearCppInlineNamespaces`, called from `clearFileLocalNames`.
+ * `clearCppInlineNamespaces`, called from
+ * `cppScopeResolver.loadResolutionConfig` at the start of every pass.
*
* STL idiom this enables: `std::__1::vector` (libc++) and `std::__cxx11`
* (libstdc++) are inline namespaces of `std`. With this support,
@@ -85,10 +87,13 @@ export function applyCppInlineNamespaceSideChannel(
for (const r of ranges) set.add(r);
}
-/** Clear all inline-namespace state. Called from `clearFileLocalNames`. */
+/** Clear all inline-namespace state. Called from
+ * `cppScopeResolver.loadResolutionConfig` at the start of every pass. */
export function clearCppInlineNamespaces(): void {
inlineNamespaceRangesByFile.clear();
inlineNamespaceScopeIds.clear();
+ qualifiedNsIndex = undefined;
+ qualifiedNsIndexSource = undefined;
}
/** Resolve captured ranges to actual ScopeIds by matching scope ranges
@@ -115,11 +120,136 @@ export function isCppInlineNamespaceScope(scopeId: ScopeId): boolean {
}
/**
- * Walk every parsed file looking for a Namespace scope whose qualified
- * name matches `receiverName`, collect its callable ownedDefs matching
- * `memberName`, transitively descending into any inline-namespace
- * children (since they're members of the enclosing namespace under ISO
- * C++).
+ * Qualified-namespace member index — built **once** per pipeline run from
+ * `parsedFiles` and reused by every qualified call site.
+ *
+ * The legacy lookup re-scanned every parsed file (rebuilding a per-file
+ * `scopesById` map each time) once **per qualified call site**, making the
+ * scope-resolution emit phase O(callsites × scopes): 25.3 min of a 33-min
+ * analyze on a 1,473-file C++ repo, 75% of total self-time in this one
+ * function (#2788). Mirrors the same fix #1990 applied to ADL
+ * (`pickCppAdlCandidates` → {@link AdlCandidateIndex}); per-site cost drops
+ * to two Map lookups.
+ *
+ * `byReceiver`: namespace simple name → member simple name → callable defs,
+ * in the exact order the legacy linear scan produced them (file-major; within
+ * a file, `parsed.scopes` declaration order; within a namespace, own
+ * `ownedDefs` before inline-namespace children, depth-first). Ordering is
+ * load-bearing: the caller returns `allHits[0]` for the single-hit case and
+ * `narrowOverloadCandidates` is first-wins.
+ */
+interface QualifiedNsMemberIndex {
+ readonly byReceiver: ReadonlyMap<string, ReadonlyMap<string, readonly SymbolDefinition[]>>;
+}
+
+type NsScope = ParsedFile['scopes'][number];
+
+let qualifiedNsIndex: QualifiedNsMemberIndex | undefined;
+let qualifiedNsIndexSource: readonly ParsedFile[] | undefined;
+
+/** Build the index in a single pass over the workspace. Visitation order
+ * mirrors the legacy scan exactly (see {@link QualifiedNsMemberIndex}). */
+function buildQualifiedNsMemberIndex(parsedFiles: readonly ParsedFile[]): QualifiedNsMemberIndex {
+ const byReceiver = new Map<string, Map<string, SymbolDefinition[]>>();
+ // Legacy dedup was a per-call `seenNodeId` set spanning all files; since a
+ // def only ever lands in one `(receiver, member)` bucket, a per-receiver set
+ // keyed `member \0 nodeId` reproduces it. Only reachable at all via
+ // same-name inline nesting (`namespace ns { inline namespace ns { … } }`),
+ // but kept so a def is never double-counted into `'ambiguous'`.
+ const seenByReceiver = new Map<string, Set<string>>();
+
+ for (const parsed of parsedFiles) {
+ // parent → inline-namespace children. The legacy transitive walk filtered
+ // `scopesById.values()` by `parent` per recursion step — O(scopes) each,
+ // and O(scopes²) per file overall; this is the same order, built once.
+ const inlineChildrenByParent = new Map<ScopeId, (typeof parsed.scopes)[number][]>();
+ for (const sc of parsed.scopes) {
+ if (sc.parent === null) continue;
+ if (sc.kind !== 'Namespace') continue;
+ if (!inlineNamespaceScopeIds.has(sc.id)) continue;
+ let kids = inlineChildrenByParent.get(sc.parent);
+ if (kids === undefined) {
+ kids = [];
+ inlineChildrenByParent.set(sc.parent, kids);
+ }
+ kids.push(sc);
+ }
+
+ for (const scope of parsed.scopes) {
+ if (scope.kind !== 'Namespace') continue;
+ const nsDef = findNamespaceDefInScope(scope);
+ if (nsDef === undefined) continue;
+ const nsName = nsDef.qualifiedName?.split('.').pop() ?? nsDef.qualifiedName ?? '';
+ let byMember = byReceiver.get(nsName);
+ if (byMember === undefined) {
+ byMember = new Map();
+ byReceiver.set(nsName, byMember);
+ }
+ let seen = seenByReceiver.get(nsName);
+ if (seen === undefined) {
+ seen = new Set();
+ seenByReceiver.set(nsName, seen);
+ }
+ collectNamespaceMembers(scope, inlineChildrenByParent, byMember, seen);
+ }
+ }
+ return { byReceiver };
+}
+
+/** Bucket a namespace scope's callable `ownedDefs` by member simple name,
+ * then descend into inline-namespace children — the index-build twin of the
+ * legacy `findMemberInNamespaceTransitive`, collecting every member name in
+ * one walk instead of one walk per `(call site, member name)`. */
+function collectNamespaceMembers(
+ scope: NsScope,
+ inlineChildrenByParent: ReadonlyMap<ScopeId, readonly NsScope[]>,
+ byMember: Map<string, SymbolDefinition[]>,
+ seen: Set<string>,
+): void {
+ for (const def of scope.ownedDefs) {
+ if (def.type !== 'Function' && def.type !== 'Method' && def.type !== 'Constructor') continue;
+ const simple = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? '';
+ const dedupKey = `${simple}\u0000${def.nodeId}`;
+ if (seen.has(dedupKey)) continue;
+ seen.add(dedupKey);
+ let arr = byMember.get(simple);
+ if (arr === undefined) {
+ arr = [];
+ byMember.set(simple, arr);
+ }
+ arr.push(def);
+ }
+ for (const child of inlineChildrenByParent.get(scope.id) ?? []) {
+ collectNamespaceMembers(child, inlineChildrenByParent, byMember, seen);
+ }
+}
+
+/** Build the index on first use of a given `parsedFiles` set; reuse it for
+ * every subsequent call site in the same pipeline run.
+ *
+ * The index is a function of TWO inputs: `parsedFiles` and the module-level
+ * `inlineNamespaceScopeIds` (which inline children get descended into).
+ * Reference identity on `parsedFiles` alone is sound here because
+ * `populateCppInlineNamespaceScopes` fills `inlineNamespaceScopeIds` during
+ * `populateOwners` — strictly before any resolution pass calls in — and
+ * {@link clearCppInlineNamespaces} drops the index at the start of every
+ * pass. Any future caller that mutates `inlineNamespaceScopeIds` mid-pass
+ * while reusing the same `parsedFiles` reference MUST call
+ * `clearCppInlineNamespaces` in between. Same contract as `ensureAdlIndex`. */
+function qualifiedNsMemberIndex(parsedFiles: readonly ParsedFile[]): QualifiedNsMemberIndex {
+ if (qualifiedNsIndex === undefined || qualifiedNsIndexSource !== parsedFiles) {
+ qualifiedNsIndex = buildQualifiedNsMemberIndex(parsedFiles);
+ qualifiedNsIndexSource = parsedFiles;
+ }
+ return qualifiedNsIndex;
+}
+
+/**
+ * Find the Namespace scopes whose simple name matches `receiverName` and
+ * return their callable members matching `memberName`, transitively
+ * including inline-namespace children (since they're members of the
+ * enclosing namespace under ISO C++). Served from a per-pipeline index
+ * ({@link QualifiedNsMemberIndex}), not a per-call-site workspace scan.
*
* Returns the most specific (innermost) match — for `outer::foo()`
* where `inline namespace v1` declares `foo`, returns `v1::foo`. When
@@ -134,27 +264,8 @@ export function resolveCppQualifiedNamespaceMember(
_scopes: ScopeResolutionIndexes,
callsite?: Callsite,
): SymbolDefinition | 'ambiguous' | undefined {
- const allHits: SymbolDefinition[] = [];
- const seenNodeId = new Set<string>();
- for (const parsed of parsedFiles) {
- const scopesById = new Map<ScopeId, (typeof parsed.scopes)[number]>();
- for (const sc of parsed.scopes) scopesById.set(sc.id, sc);
- for (const scope of parsed.scopes) {
- if (scope.kind !== 'Namespace') continue;
- const nsDef = findNamespaceDefInScope(scope);
- if (nsDef === undefined) continue;
- const nsName = nsDef.qualifiedName?.split('.').pop() ?? nsDef.qualifiedName ?? '';
- if (nsName !== receiverName) continue;
- // Found a matching namespace scope in this file. Collect ALL
- // members transitively through any inline-namespace children.
- const hits = findMemberInNamespaceTransitive(scope, scopesById, memberName);
- for (const hit of hits) {
- if (seenNodeId.has(hit.nodeId)) continue;
- seenNodeId.add(hit.nodeId);
- allHits.push(hit);
- }
- }
- }
+ const allHits =
+ qualifiedNsMemberIndex(parsedFiles).byReceiver.get(receiverName)?.get(memberName) ?? [];
if (allHits.length === 0) return undefined;
if (allHits.length === 1) return allHits[0];
@@ -182,46 +293,6 @@ export function resolveCppQualifiedNamespaceMember(
return 'ambiguous';
}
-/** Recursively search a namespace scope and any inline-namespace
- * descendants for callable defs with the given simple name. Non-inline
- * nested namespaces are NOT traversed — they require explicit
- * qualification (`outer::nested::foo`). Returns ALL matches so the
- * caller can detect same-name ambiguity across inline children (#1564). */
-function findMemberInNamespaceTransitive(
- scope: {
- readonly id: ScopeId;
- readonly ownedDefs: readonly SymbolDefinition[];
- readonly parent: ScopeId | null;
- },
- scopesById: ReadonlyMap<
- ScopeId,
- {
- readonly id: ScopeId;
- readonly kind: string;
- readonly parent: ScopeId | null;
- readonly ownedDefs: readonly SymbolDefinition[];
- }
- >,
- memberName: string,
-): SymbolDefinition[] {
- const results: SymbolDefinition[] = [];
- // Check this scope's own ownedDefs first.
- for (const def of scope.ownedDefs) {
- if (def.type !== 'Function' && def.type !== 'Method' && def.type !== 'Constructor') continue;
- const simple = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? '';
- if (simple === memberName) results.push(def);
- }
- // Descend into inline-namespace children.
- for (const childScope of scopesById.values()) {
- if (childScope.parent !== scope.id) continue;
- if (childScope.kind !== 'Namespace') continue;
- if (!inlineNamespaceScopeIds.has(childScope.id)) continue;
- const childHits = findMemberInNamespaceTransitive(childScope, scopesById, memberName);
- for (const hit of childHits) results.push(hit);
- }
- return results;
-}
-
function findNamespaceDefInScope(scope: {
readonly ownedDefs: readonly SymbolDefinition[];
}): SymbolDefinition | undefined {
diff --git a/gitnexus/test/unit/cpp-qualified-ns-index.test.ts b/gitnexus/test/unit/cpp-qualified-ns-index.test.ts
new file mode 100644
index 000000000..a8af02384
--- /dev/null
+++ b/gitnexus/test/unit/cpp-qualified-ns-index.test.ts
@@ -0,0 +1,258 @@
+/**
+ * #2788 — `resolveCppQualifiedNamespaceMember` serves qualified `ns::member()`
+ * lookups from a per-pipeline index instead of rescanning every parsed file per
+ * call site. These tests pin the two properties the index must not lose:
+ *
+ * 1. Transitive inline-namespace collection, ordering and same-name
+ * ambiguity (#1564) — the semantics the old linear scan provided.
+ * 2. Cache invalidation — a new `parsedFiles` array, or a
+ * `clearCppInlineNamespaces()` between passes, must not serve stale hits.
+ * This is the failure mode the index introduces; nothing else covers it.
+ */
+
+import type {
+ ParsedFile,
+ ScopeId,
+ ScopeResolutionIndexes,
+ SymbolDefinition,
+} from 'gitnexus-shared';
+import { beforeEach, describe, expect, it } from 'vitest';
+import {
+ clearCppInlineNamespaces,
+ markCppInlineNamespaceRange,
+ populateCppInlineNamespaceScopes,
+ resolveCppQualifiedNamespaceMember,
+} from '../../src/core/ingestion/languages/cpp/inline-namespaces.js';
+
+const NO_SCOPES = {} as unknown as ScopeResolutionIndexes;
+
+interface ScopeSpec {
+ readonly id: string;
+ readonly kind: 'Namespace' | 'Module';
+ readonly parent: string | null;
+ readonly defs: readonly SymbolDefinition[];
+ /** Distinguishes each scope's range so inline marking targets exactly one. */
+ readonly line: number;
+}
+
+function def(nodeId: string, type: string, qualifiedName: string): SymbolDefinition {
+ return { nodeId, type, qualifiedName } as unknown as SymbolDefinition;
+}
+
+function nsDef(nodeId: string, qualifiedName: string): SymbolDefinition {
+ return def(nodeId, 'Namespace', qualifiedName);
+}
+
+function fnDef(nodeId: string, qualifiedName: string): SymbolDefinition {
+ return def(nodeId, 'Function', qualifiedName);
+}
+
+function range(line: number): {
+ startLine: number;
+ startCol: number;
+ endLine: number;
+ endCol: number;
+} {
+ return { startLine: line, startCol: 0, endLine: line + 1, endCol: 0 };
+}
+
+/** Build a single-file `parsedFiles` array from scope specs, marking the
+ * scopes named in `inlineIds` as inline namespaces (capture-time range mark +
+ * `populateOwners`-time scope-id resolution, same order as the pipeline). */
+function makeParsedFiles(
+ filePath: string,
+ specs: readonly ScopeSpec[],
+ inlineIds: readonly string[],
+): readonly ParsedFile[] {
+ const parsed = {
+ filePath,
+ scopes: specs.map((s) => ({
+ id: s.id as unknown as ScopeId,
+ kind: s.kind,
+ parent: s.parent as unknown as ScopeId | null,
+ ownedDefs: s.defs,
+ range: range(s.line),
+ })),
+ } as unknown as ParsedFile;
+ markInline(parsed, specs, inlineIds);
+ return [parsed];
+}
+
+/** Capture-time inline marking + `populateOwners`-time scope-id resolution,
+ * in the same order the pipeline runs them. */
+function markInline(
+ parsed: ParsedFile,
+ specs: readonly ScopeSpec[],
+ inlineIds: readonly string[],
+): void {
+ for (const id of inlineIds) {
+ const spec = specs.find((s) => s.id === id);
+ if (spec === undefined) throw new Error(`inline scope ${id} must exist`);
+ markCppInlineNamespaceRange(parsed.filePath, range(spec.line));
+ }
+ populateCppInlineNamespaceScopes(parsed);
+}
+
+/** `namespace outer { <ownDefs> inline namespace v1 { <inlineDefs> } }` */
+function outerWithInlineChild(
+ filePath: string,
+ ownDefs: readonly SymbolDefinition[],
+ inlineDefs: readonly SymbolDefinition[],
+): readonly ParsedFile[] {
+ return makeParsedFiles(
+ filePath,
+ [
+ {
+ id: 'sc:outer',
+ kind: 'Namespace',
+ parent: null,
+ defs: [nsDef('n:outer', 'outer'), ...ownDefs],
+ line: 1,
+ },
+ {
+ id: 'sc:v1',
+ kind: 'Namespace',
+ parent: 'sc:outer',
+ defs: [nsDef('n:v1', 'outer.v1'), ...inlineDefs],
+ line: 10,
+ },
+ ],
+ ['sc:v1'],
+ );
+}
+
+describe('C++ qualified-namespace member index (#2788)', () => {
+ beforeEach(() => {
+ clearCppInlineNamespaces();
+ });
+
+ it('resolves outer::foo through an inline-namespace child', () => {
+ const files = outerWithInlineChild('a.cpp', [], [fnDef('n:foo@v1', 'outer.v1.foo')]);
+ expect(resolveCppQualifiedNamespaceMember('outer', 'foo', files, NO_SCOPES)).toMatchObject({
+ nodeId: 'n:foo@v1',
+ });
+ });
+
+ it('returns undefined for an unknown namespace or member', () => {
+ const files = outerWithInlineChild('a.cpp', [], [fnDef('n:foo@v1', 'outer.v1.foo')]);
+ expect(resolveCppQualifiedNamespaceMember('nope', 'foo', files, NO_SCOPES)).toBeUndefined();
+ expect(resolveCppQualifiedNamespaceMember('outer', 'nope', files, NO_SCOPES)).toBeUndefined();
+ });
+
+ it('does not descend into a non-inline nested namespace', () => {
+ const files = makeParsedFiles(
+ 'a.cpp',
+ [
+ {
+ id: 'sc:outer',
+ kind: 'Namespace',
+ parent: null,
+ defs: [nsDef('n:outer', 'outer')],
+ line: 1,
+ },
+ {
+ id: 'sc:nested',
+ kind: 'Namespace',
+ parent: 'sc:outer',
+ defs: [nsDef('n:nested', 'outer.nested'), fnDef('n:foo@nested', 'outer.nested.foo')],
+ line: 10,
+ },
+ ],
+ [],
+ );
+ expect(resolveCppQualifiedNamespaceMember('outer', 'foo', files, NO_SCOPES)).toBeUndefined();
+ expect(resolveCppQualifiedNamespaceMember('nested', 'foo', files, NO_SCOPES)).toMatchObject({
+ nodeId: 'n:foo@nested',
+ });
+ });
+
+ it('reports same-name hits across two inline children as ambiguous (#1564)', () => {
+ const files = makeParsedFiles(
+ 'a.cpp',
+ [
+ {
+ id: 'sc:outer',
+ kind: 'Namespace',
+ parent: null,
+ defs: [nsDef('n:outer', 'outer')],
+ line: 1,
+ },
+ {
+ id: 'sc:v1',
+ kind: 'Namespace',
+ parent: 'sc:outer',
+ defs: [nsDef('n:v1', 'outer.v1'), fnDef('n:foo@v1', 'outer.v1.foo')],
+ line: 10,
+ },
+ {
+ id: 'sc:v2',
+ kind: 'Namespace',
+ parent: 'sc:outer',
+ defs: [nsDef('n:v2', 'outer.v2'), fnDef('n:foo@v2', 'outer.v2.foo')],
+ line: 20,
+ },
+ ],
+ ['sc:v1', 'sc:v2'],
+ );
+ expect(resolveCppQualifiedNamespaceMember('outer', 'foo', files, NO_SCOPES)).toBe('ambiguous');
+ });
+
+ it('keeps scan order: a namespace-owned def precedes its inline child hits', () => {
+ // Two candidates with no call-site info narrow to 'ambiguous', so order is
+ // asserted through the single-hit path: only the own def is present here,
+ // and the inline child contributes a different member name.
+ const files = outerWithInlineChild(
+ 'a.cpp',
+ [fnDef('n:foo@outer', 'outer.foo')],
+ [fnDef('n:bar@v1', 'outer.v1.bar')],
+ );
+ expect(resolveCppQualifiedNamespaceMember('outer', 'foo', files, NO_SCOPES)).toMatchObject({
+ nodeId: 'n:foo@outer',
+ });
+ expect(resolveCppQualifiedNamespaceMember('outer', 'bar', files, NO_SCOPES)).toMatchObject({
+ nodeId: 'n:bar@v1',
+ });
+ });
+
+ it('does not serve one parsedFiles arrays index to another', () => {
+ const first = outerWithInlineChild('a.cpp', [], [fnDef('n:foo@a', 'outer.v1.foo')]);
+ expect(resolveCppQualifiedNamespaceMember('outer', 'foo', first, NO_SCOPES)).toMatchObject({
+ nodeId: 'n:foo@a',
+ });
+ const second = outerWithInlineChild('b.cpp', [], [fnDef('n:foo@b', 'outer.v1.foo')]);
+ expect(resolveCppQualifiedNamespaceMember('outer', 'foo', second, NO_SCOPES)).toMatchObject({
+ nodeId: 'n:foo@b',
+ });
+ });
+
+ it('rebuilds after clearCppInlineNamespaces even when parsedFiles is reused', () => {
+ // Pass 1: `v1` is inline, so `outer::foo` reaches through it.
+ const specs: readonly ScopeSpec[] = [
+ {
+ id: 'sc:outer',
+ kind: 'Namespace',
+ parent: null,
+ defs: [nsDef('n:outer', 'outer')],
+ line: 1,
+ },
+ {
+ id: 'sc:v1',
+ kind: 'Namespace',
+ parent: 'sc:outer',
+ defs: [nsDef('n:v1', 'outer.v1'), fnDef('n:foo@v1', 'outer.v1.foo')],
+ line: 10,
+ },
+ ];
+ const files = makeParsedFiles('a.cpp', specs, ['sc:v1']);
+ expect(resolveCppQualifiedNamespaceMember('outer', 'foo', files, NO_SCOPES)).toMatchObject({
+ nodeId: 'n:foo@v1',
+ });
+
+ // Pass 2: SAME `parsedFiles` reference (so identity alone would serve the
+ // cached index), but `v1` is no longer inline. Without the index reset in
+ // `clearCppInlineNamespaces` the stale pass-1 hit survives.
+ clearCppInlineNamespaces();
+ markInline(files[0], specs, []);
+ expect(resolveCppQualifiedNamespaceMember('outer', 'foo', files, NO_SCOPES)).toBeUndefined();
+ });
+});

View file

@ -1,723 +0,0 @@
diff --git a/SECURITY.md b/SECURITY.md
index 89368a1ea..6199e8b69 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -65,6 +65,10 @@ The `render.yaml` Blueprint (see the README's **Deploy to Render**) puts `gitnex
Do not hand the URL out as a public demo. A token holder has read access to everything the deploy has indexed.
+### `/api/grep` regex semantics and residual ReDoS exposure
+
+`GET /api/grep` executes caller-supplied patterns as real regular expressions (with an optional path-substring `fileFilter` and `caseSensitive` flag) to honor the web chat's grep tool contract; `literal=1` restores the older escaped-substring mode that was accidentally ReDoS-immune. Mitigations: a 200-character pattern cap, line-by-line matching, a max-200 result cap, and a 5-second wall-clock budget enforced between files and between lines (a timed-out scan returns partial results with `timedOut: true`; the web grep tool surfaces that flag so an agent does not treat a cut-off scan as exhaustive). **What these do not cover:** a single catastrophically backtracking pattern (e.g. `(a+)+$`) blocks the Node event loop synchronously inside one `regex.test()` call — the budget cannot interrupt it, and during that window the whole server is unresponsive (measured: a 35-character line exceeds 120s). Exposure is accepted because loopback-bound local serves see only trusted input, hosted deploys gate the route behind the edge token, and agent-generated patterns are length-capped; a proper worker-thread sandbox with `terminate()` (or an optional `re2` dependency) is the known follow-up. If you expose `serve` beyond loopback to parties you do not fully trust, prefer keeping literal mode for untrusted callers.
+
## Automated Scans Running in CI
This repository runs the following scans automatically. Findings appear under the repository's **Security → Code scanning** tab.
diff --git a/gitnexus-web/src/core/llm/tools.ts b/gitnexus-web/src/core/llm/tools.ts
index 407018678..aeb753e7f 100644
--- a/gitnexus-web/src/core/llm/tools.ts
+++ b/gitnexus-web/src/core/llm/tools.ts
@@ -14,7 +14,7 @@
import { tool } from '@langchain/core/tools';
import { z } from 'zod';
import { NODE_TABLES, REL_TYPES, scoreImpactRisk, unusedAxesForImpactWalk } from 'gitnexus-shared';
-import type { EnrichedSearchResult, GrepResult } from '../../services/backend-client';
+import type { EnrichedSearchResult, GrepResponse } from '../../services/backend-client';
/**
* Tool names registered by createGraphRAGTools — kept in sync with each tool's `name`
@@ -44,7 +44,11 @@ export interface GraphRAGBackend {
query: string,
opts?: { limit?: number; mode?: 'hybrid' | 'semantic' | 'bm25'; enrich?: boolean },
) => Promise<EnrichedSearchResult[]>;
- grep: (pattern: string, limit?: number) => Promise<GrepResult[]>;
+ grep: (
+ pattern: string,
+ limit?: number,
+ opts?: { fileFilter?: string; caseSensitive?: boolean },
+ ) => Promise<GrepResponse>;
readFile: (filePath: string) => Promise<string>;
}
@@ -375,20 +379,22 @@ MATCH (n:Function {id: emb.nodeId}) RETURN n`,
}
const limit = maxResults ?? 100;
- const fullPattern = fileFilter
- ? `(?=.*${fileFilter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}).*${pattern}`
- : pattern;
-
- const results = await backendGrep(fullPattern, limit);
+ const { results, timedOut } = await backendGrep(pattern, limit, {
+ fileFilter: fileFilter ?? undefined,
+ caseSensitive,
+ });
+ const timeoutMsg = timedOut
+ ? '\n\n(Scan timed out after a few seconds — results may be incomplete)'
+ : '';
if (results.length === 0) {
- return `No matches for "${pattern}"${fileFilter ? ` in files matching "${fileFilter}"` : ''}`;
+ return `No matches for "${pattern}"${fileFilter ? ` in files matching "${fileFilter}"` : ''}${timeoutMsg}`;
}
const formatted = results.map((r) => `${r.filePath}:${r.line}: ${r.text}`).join('\n');
const truncatedMsg = results.length >= limit ? `\n\n(Showing first ${limit} results)` : '';
- return `Found ${results.length} matches:\n\n${formatted}${truncatedMsg}`;
+ return `Found ${results.length} matches:\n\n${formatted}${truncatedMsg}${timeoutMsg}`;
} catch (error) {
return `Grep error: ${error instanceof Error ? error.message : String(error)}`;
}
@@ -396,16 +402,20 @@ MATCH (n:Function {id: emb.nodeId}) RETURN n`,
{
name: 'grep',
description:
- 'Search for exact text patterns across all files using regex. Use for finding specific strings, error messages, TODOs, variable names, etc.',
+ 'Search file contents with a regular expression (server executes it as a real regex — alternation like "sign|Sign" works). Matches are case-insensitive unless caseSensitive is set. fileFilter keeps only files whose path contains the substring. Each call caps at maxResults matches (default 100) and the server stops after a few seconds (the tool will say so if the scan was incomplete), so prefer precise patterns over catch-alls.',
schema: z.object({
pattern: z
.string()
- .describe('Regex pattern to search for (e.g., "TODO", "console\\.log", "API_KEY")'),
+ .describe(
+ 'Regex pattern to search for (e.g., "TODO|FIXME", "console\\.log", "signOrder")',
+ ),
fileFilter: z
.string()
.optional()
.nullable()
- .describe('Only search files containing this string (e.g., ".ts", "src/api")'),
+ .describe(
+ 'Only search files whose path contains this substring (e.g., ".ts", "src/api", "Controller.java")',
+ ),
caseSensitive: z
.boolean()
.optional()
@@ -1219,7 +1229,7 @@ MATCH (n:Function {id: emb.nodeId}) RETURN n`,
const targetFileName = (targetFilePath || target).split('/').pop() || target;
const baseName = targetFileName.replace(/\.[^/.]+$/, '');
try {
- const hints = await backendGrep(`\\b${escapeRegex(baseName)}\\b`, 15);
+ const { results: hints } = await backendGrep(`\\b${escapeRegex(baseName)}\\b`, 15);
const filtered = hints.filter((h) => h.filePath !== targetFilePath);
if (filtered.length > 0) {
diff --git a/gitnexus-web/src/hooks/useAppState.tsx b/gitnexus-web/src/hooks/useAppState.tsx
index d698b87d5..8aa183d35 100644
--- a/gitnexus-web/src/hooks/useAppState.tsx
+++ b/gitnexus-web/src/hooks/useAppState.tsx
@@ -671,7 +671,11 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
const backend = {
executeQuery,
search: (query: string, opts?: any) => backendSearch(query, { ...opts, repo }),
- grep: (pattern: string, limit?: number) => backendGrep(pattern, repo, limit),
+ grep: (
+ pattern: string,
+ limit?: number,
+ opts?: { fileFilter?: string; caseSensitive?: boolean },
+ ) => backendGrep(pattern, repo, limit, opts),
readFile: (filePath: string) =>
backendReadFile(filePath, { repo }).then((r) => r.content),
};
diff --git a/gitnexus-web/src/services/backend-client.ts b/gitnexus-web/src/services/backend-client.ts
index 52f8c65c9..54ff9b82f 100644
--- a/gitnexus-web/src/services/backend-client.ts
+++ b/gitnexus-web/src/services/backend-client.ts
@@ -64,6 +64,12 @@ export interface GrepResult {
text: string;
}
+/** Full `/api/grep` payload — `timedOut` means the 5s budget cut the scan short. */
+export interface GrepResponse {
+ results: GrepResult[];
+ timedOut?: boolean;
+}
+
export interface JobProgress {
phase: string;
percent: number;
@@ -869,23 +875,37 @@ export const search = async (
return (body.results ?? []) as EnrichedSearchResult[];
};
-/** Grep across file contents in the indexed repo. */
+/** Options for {@link grep} beyond pattern/repo/limit. */
+export interface GrepOptions {
+ /** Only search files whose path contains this substring (case-insensitive). */
+ fileFilter?: string;
+ /** Case-sensitive matching (default: insensitive). */
+ caseSensitive?: boolean;
+}
+
+/** Grep across file contents in the indexed repo. Regex semantics server-side. */
export const grep = async (
pattern: string,
repo?: string,
limit?: number,
-): Promise<GrepResult[]> => {
+ opts?: GrepOptions,
+): Promise<GrepResponse> => {
const params = [
`pattern=${encodeURIComponent(pattern)}`,
repoParam(repo),
limit ? `limit=${limit}` : '',
+ opts?.fileFilter ? `fileFilter=${encodeURIComponent(opts.fileFilter)}` : '',
+ opts?.caseSensitive ? 'caseSensitive=1' : '',
]
.filter(Boolean)
.join('&');
const response = await fetchWithTimeout(`${_backendUrl}/api/grep?${params}`);
await assertOk(response);
- const body = await response.json();
- return (body.results ?? []) as GrepResult[];
+ const body = (await response.json()) as { results?: GrepResult[]; timedOut?: unknown };
+ return {
+ results: (body.results ?? []) as GrepResult[],
+ ...(body.timedOut === true ? { timedOut: true as const } : {}),
+ };
};
/** Result from reading a file, optionally with line range. */
diff --git a/gitnexus-web/test/unit/agent-prompt.test.ts b/gitnexus-web/test/unit/agent-prompt.test.ts
index c2cb1392f..4751b6be5 100644
--- a/gitnexus-web/test/unit/agent-prompt.test.ts
+++ b/gitnexus-web/test/unit/agent-prompt.test.ts
@@ -43,7 +43,7 @@ const FORBIDDEN_TOOL_NAMES = [
const stubBackend: GraphRAGBackend = {
executeQuery: async () => [],
search: async () => [],
- grep: async () => [],
+ grep: async () => ({ results: [] }),
readFile: async () => '',
};
diff --git a/gitnexus-web/test/unit/backend-client-grep.test.ts b/gitnexus-web/test/unit/backend-client-grep.test.ts
new file mode 100644
index 000000000..e5b6189e2
--- /dev/null
+++ b/gitnexus-web/test/unit/backend-client-grep.test.ts
@@ -0,0 +1,76 @@
+/**
+ * `/api/grep` client: query params and `timedOut` must reach callers.
+ * Dropping `timedOut` made a 5s partial scan look like a complete miss.
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { __resetBreakerRegistry__ } from 'gitnexus-shared/test-helpers';
+import { grep, setBackendUrl } from '../../src/services/backend-client';
+
+const BASE = 'http://grep-client.test:4747';
+
+describe('backend-client grep', () => {
+ beforeEach(() => {
+ __resetBreakerRegistry__();
+ setBackendUrl(BASE);
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it('forwards fileFilter and caseSensitive and returns timedOut', async () => {
+ const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
+ const url = String(input);
+ expect(url).toContain('/api/grep?');
+ expect(url).toContain(`pattern=${encodeURIComponent('sign|Sign')}`);
+ expect(url).toContain(`fileFilter=${encodeURIComponent('src/api')}`);
+ expect(url).toContain('caseSensitive=1');
+ expect(url).toContain('limit=12');
+ return new Response(
+ JSON.stringify({
+ results: [{ filePath: 'src/api.ts', line: 3, text: 'signOrder()' }],
+ timedOut: true,
+ }),
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
+ );
+ });
+ vi.stubGlobal('fetch', fetchMock);
+
+ const body = await grep('sign|Sign', '/repo', 12, {
+ fileFilter: 'src/api',
+ caseSensitive: true,
+ });
+ expect(body.results).toEqual([{ filePath: 'src/api.ts', line: 3, text: 'signOrder()' }]);
+ expect(body.timedOut).toBe(true);
+ });
+
+ it('omits timedOut when the server completed the scan', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => {
+ return new Response(JSON.stringify({ results: [] }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }),
+ );
+
+ const body = await grep('TODO');
+ expect(body).toEqual({ results: [] });
+ });
+
+ it('does not send fileFilter when it is null or empty', async () => {
+ const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
+ const url = String(input);
+ expect(url).not.toContain('fileFilter=');
+ return new Response(JSON.stringify({ results: [] }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ });
+ vi.stubGlobal('fetch', fetchMock);
+
+ await grep('x', undefined, undefined, { fileFilter: '' });
+ expect(fetchMock).toHaveBeenCalled();
+ });
+});
diff --git a/gitnexus-web/test/unit/grep-tool.test.ts b/gitnexus-web/test/unit/grep-tool.test.ts
new file mode 100644
index 000000000..2a904ec1a
--- /dev/null
+++ b/gitnexus-web/test/unit/grep-tool.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it, vi } from 'vitest';
+import { createGraphRAGTools, type GraphRAGBackend } from '../../src/core/llm/tools';
+
+const noOpBackend: GraphRAGBackend = {
+ executeQuery: async () => [],
+ search: async () => [],
+ grep: async () => ({ results: [] }),
+ readFile: async () => '',
+};
+
+function grepTool(backend: GraphRAGBackend) {
+ return createGraphRAGTools(backend).find((candidate) => candidate.name === 'grep')!;
+}
+
+describe('grep tool timeout contract', () => {
+ it('says the scan was incomplete when the server sets timedOut with no hits', async () => {
+ const grep = vi.fn(async () => ({ results: [] as const, timedOut: true as const }));
+ const output = await grepTool({ ...noOpBackend, grep }).invoke({ pattern: 'signOrder' });
+ expect(output).toContain('No matches for "signOrder"');
+ expect(output).toContain('results may be incomplete');
+ });
+
+ it('still warns when a timed-out scan returned some hits below the limit', async () => {
+ const grep = vi.fn(async () => ({
+ results: [{ filePath: 'a.ts', line: 1, text: 'signOrder()' }],
+ timedOut: true as const,
+ }));
+ const output = await grepTool({ ...noOpBackend, grep }).invoke({
+ pattern: 'signOrder',
+ maxResults: 100,
+ });
+ expect(output).toContain('Found 1 matches');
+ expect(output).toContain('results may be incomplete');
+ expect(output).not.toContain('Showing first');
+ });
+});
diff --git a/gitnexus-web/test/unit/impact-tool.test.ts b/gitnexus-web/test/unit/impact-tool.test.ts
index f04817ed1..9162a50eb 100644
--- a/gitnexus-web/test/unit/impact-tool.test.ts
+++ b/gitnexus-web/test/unit/impact-tool.test.ts
@@ -4,7 +4,7 @@ import { createGraphRAGTools, type GraphRAGBackend } from '../../src/core/llm/to
const noOpBackend: GraphRAGBackend = {
executeQuery: async () => [],
search: async () => [],
- grep: async () => [],
+ grep: async () => ({ results: [] }),
readFile: async () => '',
};
diff --git a/gitnexus/src/server/api.ts b/gitnexus/src/server/api.ts
index fc2e7d943..0716911e6 100644
--- a/gitnexus/src/server/api.ts
+++ b/gitnexus/src/server/api.ts
@@ -54,7 +54,8 @@ import {
persistedEmbeddingCountOrUndefined,
type PersistedEmbeddingCount,
} from '../core/embedding-count.js';
-import { assertString, escapeRegExp, BadRequestError, createRouteLimiter } from './validation.js';
+import { assertString, BadRequestError, createRouteLimiter } from './validation.js';
+import { parseGrepQuery, GREP_TIME_BUDGET_MS } from './grep-params.js';
import {
extractRepoName,
getCloneDir,
@@ -1337,45 +1338,18 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
res.status(404).json({ error: 'Repository not found' });
return;
}
- // Type-confusion guard (CodeQL js/type-confusion-through-parameter-tampering):
- // req.query.pattern is `string | string[] | ParsedQs` — without an explicit
- // type check, the `.length` guard below counts array elements instead of
- // characters, allowing arbitrarily long patterns through.
- const rawPattern = req.query.pattern;
- if (rawPattern === undefined) {
- res.status(400).json({ error: 'Missing "pattern" query parameter' });
- return;
- }
- const pattern = assertString(rawPattern, 'pattern');
- if (pattern.length === 0) {
- res.status(400).json({ error: 'Missing "pattern" query parameter' });
- return;
- }
-
- // Length cap: applies to both literal and regex modes as a defense-in-depth
- // bound against pathological input.
- if (pattern.length > 200) {
- res.status(400).json({ error: 'Pattern too long (max 200 characters)' });
- return;
- }
-
- // Treat user input as a literal substring in all cases to prevent
- // regex-injection/ReDoS via attacker-controlled regex syntax.
- const effectivePattern = escapeRegExp(pattern);
-
- // Validate regex syntax (catches both opt-in user regex and any escapeRegExp bug)
- let regex: RegExp;
- try {
- regex = new RegExp(effectivePattern, 'gim');
- } catch {
- res.status(400).json({ error: 'Invalid regex pattern' });
- return;
- }
-
- const parsedLimit = Number(req.query.limit ?? 50);
- const limit = Number.isFinite(parsedLimit)
- ? Math.max(1, Math.min(200, Math.trunc(parsedLimit)))
- : 50;
+ // Pattern parsing (regex construction, fileFilter, case flag, limit
+ // clamping) lives in grep-params.ts so the contract is unit-testable
+ // without this module's native imports. BadRequestError thrown there is
+ // mapped to 400 by statusFromError in the catch below — including the
+ // CodeQL js/type-confusion-through-parameter-tampering guard for
+ // array-form query params. ReDoS caveat: the wall-clock budget below
+ // is checked BETWEEN files only — a single catastrophically
+ // backtracking regex.test() blocks the event loop synchronously and
+ // cannot be interrupted (see grep-params.ts and SECURITY.md for the
+ // accepted-risk rationale; literal=1 restores full immunity).
+ const { regex, fileFilter, limit } = parseGrepQuery(req.query as Record<string, unknown>);
+ const deadline = Date.now() + GREP_TIME_BUDGET_MS;
const results: { filePath: string; line: number; text: string }[] = [];
const repoRoot = path.resolve(entry.path);
@@ -1389,10 +1363,19 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
{ readOnly: true },
);
- // Search files on disk one at a time (constant memory)
+ // Search files on disk one at a time (constant memory); the wall-clock
+ // budget stops the scan between files on large repos and reports
+ // timedOut so callers can distinguish truncation from a full scan.
+ // It is NOT a per-test circuit breaker — see the caveat above.
+ let timedOut = false;
for (const row of fileRows) {
if (results.length >= limit) break;
+ if (Date.now() > deadline) {
+ timedOut = true;
+ break;
+ }
const filePath: string = row.filePath || '';
+ if (fileFilter && !filePath.toLowerCase().includes(fileFilter)) continue;
const fullPath = path.resolve(repoRoot, filePath);
// Path traversal guard
@@ -1409,14 +1392,18 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
if (results.length >= limit) break;
+ if (Date.now() > deadline) {
+ timedOut = true;
+ break;
+ }
if (regex.test(lines[i])) {
results.push({ filePath, line: i + 1, text: lines[i].trim().slice(0, 200) });
}
- regex.lastIndex = 0;
}
+ if (timedOut) break;
}
- res.json({ results });
+ res.json({ results, ...(timedOut ? { timedOut: true } : {}) });
} catch (err: any) {
res.status(statusFromError(err)).json({ error: err.message || 'Grep failed' });
}
diff --git a/gitnexus/src/server/grep-params.ts b/gitnexus/src/server/grep-params.ts
new file mode 100644
index 000000000..97ec02092
--- /dev/null
+++ b/gitnexus/src/server/grep-params.ts
@@ -0,0 +1,106 @@
+/**
+ * Query-parameter parsing for GET /api/grep.
+ *
+ * Extracted from api.ts so the contract is unit-testable without pulling
+ * Express + the LadybugDB native adapter into the test run (same rationale
+ * as the #2790 helper extraction).
+ *
+ * Contract fix (Patch 12): the grep tool schema in gitnexus-web has always
+ * promised regex search with an optional path-substring filter and
+ * case-sensitivity control, but the handler used to escapeRegExp() every
+ * pattern into a literal substring — an agent asking for "sign|Sign" got
+ * zero hits and concluded the code didn't exist, and the schema's own
+ * example ("console\.log") could never match. This restores the promised
+ * semantics. The literal-only era was accidentally ReDoS-immune; that
+ * immunity is knowingly traded back for the promised contract, with the
+ * bounded mitigations below. IMPORTANT — what these mitigations do NOT
+ * cover: a single catastrophic-backtracking regex.test() blocks the Node
+ * event loop synchronously and the wall-clock budget (checked between
+ * files, never inside a test) cannot interrupt it; a pattern like
+ * (a+)+$ against a 35-char line measurably exceeds 120s. During that
+ * window the whole server (all routes + SSE) is unresponsive. Accepted
+ * because: local serve binds loopback by default, hosted deploys gate
+ * /api/grep behind the edge token (see SECURITY.md), the pattern cap
+ * bounds construction cost, line-by-line matching bounds per-test input
+ * length, and the result cap bounds total work. The proper fix — running
+ * the scan in a worker_threads worker with terminate() on timeout, or an
+ * optional re2 dependency — is deliberately out of scope here.
+ * - pattern length cap (200 chars, unchanged from the literal-only era)
+ * - line-by-line matching (each regex.test call sees one source line)
+ * - a wall-clock budget the handler enforces between files
+ * - result cap unchanged (limit, max 200)
+ * - literal=1 opt-out restores the old escaped-substring immunity
+ */
+import { assertString, escapeRegExp, BadRequestError } from './validation.js';
+
+/** Hard cap on pattern length — unchanged from the literal-only era. */
+export const GREP_PATTERN_MAX_LENGTH = 200;
+
+/** Wall-clock budget for one /api/grep call, enforced between files. */
+export const GREP_TIME_BUDGET_MS = 5_000;
+
+export const GREP_DEFAULT_LIMIT = 50;
+export const GREP_MAX_LIMIT = 200;
+
+export interface ParsedGrepQuery {
+ regex: RegExp;
+ /** Lowercased path substring; '' disables path filtering. */
+ fileFilter: string;
+ limit: number;
+}
+
+const isFlagTrue = (value: unknown, name: string): boolean => {
+ const s = assertString(value ?? '', name).toLowerCase();
+ return s === '1' || s === 'true';
+};
+
+/**
+ * Parse /api/grep query parameters into a ready-to-use regex + filters.
+ * Throws BadRequestError (mapped to HTTP 400 by statusFromError) on
+ * missing/over-long patterns or invalid regex syntax.
+ */
+export function parseGrepQuery(query: Record<string, unknown>): ParsedGrepQuery {
+ const pattern = assertString(query.pattern, 'pattern');
+ if (pattern.length === 0) {
+ throw new BadRequestError('Missing "pattern" query parameter');
+ }
+ if (pattern.length > GREP_PATTERN_MAX_LENGTH) {
+ throw new BadRequestError(`Pattern too long (max ${GREP_PATTERN_MAX_LENGTH} characters)`);
+ }
+
+ // Regex semantics by default — what the tool schema always promised.
+ // literal=1 opts back into the escaped-substring behaviour of the
+ // literal-only era for callers that want it verbatim.
+ const effectivePattern = isFlagTrue(query.literal, 'literal') ? escapeRegExp(pattern) : pattern;
+ const caseSensitive = isFlagTrue(query.caseSensitive, 'caseSensitive');
+
+ let regex: RegExp;
+ try {
+ // Deliberately no 'g' flag: the handler tests line-by-line and a
+ // stateful lastIndex across lines would skip matches (the old handler
+ // had to reset it manually). No 'm' either: each test receives a
+ // single line, so ^/$ already anchor at string boundaries — 'm'
+ // would be a no-op.
+ //
+ // CodeQL js/regular-expression-injection — `effectivePattern` is the
+ // caller-supplied query unless `literal=1` escaped it. Intentional:
+ // the web grep tool contract is real regex. Residual ReDoS (one
+ // blocking `regex.test`) is documented in SECURITY.md; loopback
+ // default + hosted edge token bound who can send a pattern.
+ // lgtm[js/regular-expression-injection]
+ // codeql[js/regular-expression-injection]
+ regex = new RegExp(effectivePattern, caseSensitive ? '' : 'i');
+ } catch {
+ throw new BadRequestError('Invalid regex pattern');
+ }
+
+ // Path-substring filter, case-insensitive ("Controller.java", "src/api").
+ const fileFilter = assertString(query.fileFilter ?? '', 'fileFilter').toLowerCase();
+
+ const parsedLimit = Number(query.limit ?? GREP_DEFAULT_LIMIT);
+ const limit = Number.isFinite(parsedLimit)
+ ? Math.max(1, Math.min(GREP_MAX_LIMIT, Math.trunc(parsedLimit)))
+ : GREP_DEFAULT_LIMIT;
+
+ return { regex, fileFilter, limit };
+}
diff --git a/gitnexus/test/unit/grep-params.test.ts b/gitnexus/test/unit/grep-params.test.ts
new file mode 100644
index 000000000..5e5c0c72e
--- /dev/null
+++ b/gitnexus/test/unit/grep-params.test.ts
@@ -0,0 +1,161 @@
+/**
+ * Unit Tests: /api/grep query parsing (gitnexus/src/server/grep-params.ts)
+ *
+ * Patch 12 contract fix — the grep tool schema promised regex +
+ * fileFilter + caseSensitive, but the handler escaped every pattern into
+ * a literal substring. These tests pin the restored semantics:
+ * - regex is real regex (alternation, classes, quantifiers work)
+ * - literal=1 keeps the old escaped-substring behaviour opt-in
+ * - fileFilter normalizes to a lowercase path substring
+ * - caseSensitive=1 drops the 'i' flag
+ * - malformed input throws BadRequestError (→ 400 via statusFromError)
+ */
+import { describe, it, expect } from 'vitest';
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import {
+ parseGrepQuery,
+ GREP_PATTERN_MAX_LENGTH,
+ GREP_DEFAULT_LIMIT,
+ GREP_MAX_LIMIT,
+} from '../../src/server/grep-params.js';
+import { BadRequestError } from '../../src/server/validation.js';
+
+describe('parseGrepQuery — regex semantics (restored contract)', () => {
+ it('honours alternation, the case the agent was burned by', () => {
+ const { regex } = parseGrepQuery({ pattern: 'sign|Sign' });
+ expect(regex.test('signOrder(order)')).toBe(true);
+ expect(regex.test('SignOffOrderAfterDecorator')).toBe(true);
+ expect(regex.test('assign a value')).toBe(true); // substring semantics, like grep
+ expect(regex.test('unrelated line')).toBe(false);
+ });
+
+ it('supports the schema example "console\\.log" against real code lines', () => {
+ const { regex } = parseGrepQuery({ pattern: 'console\\.log' });
+ expect(regex.test('console.log("hi")')).toBe(true);
+ expect(regex.test('consolexlog("hi")')).toBe(false);
+ });
+
+ it('is case-insensitive by default', () => {
+ const { regex } = parseGrepQuery({ pattern: 'todo' });
+ expect(regex.test('TODO: fix me')).toBe(true);
+ });
+
+ it('caseSensitive=1 drops the i flag', () => {
+ const { regex } = parseGrepQuery({ pattern: 'todo', caseSensitive: '1' });
+ expect(regex.test('TODO: fix me')).toBe(false);
+ expect(regex.test('todo: fix me')).toBe(true);
+ });
+
+ it('caseSensitive=true is accepted alongside the bare flag form', () => {
+ const { regex } = parseGrepQuery({ pattern: 'todo', caseSensitive: 'true' });
+ expect(regex.test('TODO')).toBe(false);
+ });
+
+ it('literal=1 restores the old escaped-substring behaviour', () => {
+ const literal = parseGrepQuery({ pattern: 'a.b', literal: '1' });
+ expect(literal.regex.test('a.b')).toBe(true);
+ expect(literal.regex.test('axb')).toBe(false);
+
+ const regexMode = parseGrepQuery({ pattern: 'a.b' });
+ expect(regexMode.regex.test('axb')).toBe(true);
+ });
+
+ it('matches CJK identifiers/comments (the primary consumer is a Chinese-codebase team)', () => {
+ const { regex } = parseGrepQuery({ pattern: '签署|signOrder' });
+ expect(regex.test('public void signOrder() { // 医嘱签署')).toBe(true);
+ expect(regex.test('// 签署接口')).toBe(true);
+ expect(regex.test('// 撤销接口')).toBe(false);
+ });
+
+ it('anchors ^/$ at line boundaries — the handler tests one line at a time, so "m" semantics are irrelevant', () => {
+ const { regex } = parseGrepQuery({ pattern: '^import .*$' });
+ expect(regex.test('import path from "path";')).toBe(true);
+ expect(regex.test(' import path from "path";')).toBe(false);
+ });
+});
+
+describe('parseGrepQuery — fileFilter', () => {
+ it('normalizes to a lowercase path substring', () => {
+ const { fileFilter } = parseGrepQuery({ pattern: 'x', fileFilter: 'Controller.JAVA' });
+ expect(fileFilter).toBe('controller.java');
+ });
+
+ it('empty / missing fileFilter disables path filtering', () => {
+ expect(parseGrepQuery({ pattern: 'x' }).fileFilter).toBe('');
+ expect(parseGrepQuery({ pattern: 'x', fileFilter: '' }).fileFilter).toBe('');
+ });
+
+ it('array-form fileFilter is type-confusion-rejected, not partially read', () => {
+ expect(() => parseGrepQuery({ pattern: 'x', fileFilter: ['a', 'b'] })).toThrow(BadRequestError);
+ });
+});
+
+describe('parseGrepQuery — limit clamping', () => {
+ it('clamps above the max', () => {
+ expect(parseGrepQuery({ pattern: 'x', limit: '5000' }).limit).toBe(GREP_MAX_LIMIT);
+ });
+
+ it('clamps below 1', () => {
+ expect(parseGrepQuery({ pattern: 'x', limit: '-5' }).limit).toBe(1);
+ });
+
+ it('falls back to the default on non-numeric input', () => {
+ expect(parseGrepQuery({ pattern: 'x', limit: 'abc' }).limit).toBe(GREP_DEFAULT_LIMIT);
+ });
+
+ it('defaults when absent', () => {
+ expect(parseGrepQuery({ pattern: 'x' }).limit).toBe(GREP_DEFAULT_LIMIT);
+ });
+});
+
+describe('parseGrepQuery — error paths (→ 400 via statusFromError)', () => {
+ it('rejects a missing pattern', () => {
+ expect(() => parseGrepQuery({})).toThrow(BadRequestError);
+ expect(() => parseGrepQuery({ pattern: '' })).toThrow(BadRequestError);
+ });
+
+ it('rejects array-form patterns (type-confusion guard unchanged)', () => {
+ try {
+ parseGrepQuery({ pattern: ['a', 'b'] });
+ expect.unreachable();
+ } catch (err) {
+ expect(err).toBeInstanceOf(BadRequestError);
+ expect((err as BadRequestError).status).toBe(400);
+ expect((err as Error).message).toContain('pattern');
+ }
+ });
+
+ it('rejects over-long patterns at the same 200-char cap', () => {
+ const long = 'a'.repeat(GREP_PATTERN_MAX_LENGTH + 1);
+ expect(() => parseGrepQuery({ pattern: long })).toThrow(/max 200 characters/);
+ expect(() => parseGrepQuery({ pattern: 'a'.repeat(GREP_PATTERN_MAX_LENGTH) })).not.toThrow();
+ });
+
+ it('rejects syntactically invalid regex', () => {
+ expect(() => parseGrepQuery({ pattern: '((' })).toThrow(/Invalid regex/);
+ });
+});
+
+describe('/api/grep handler wiring (source-level, api-readonly-wiring.test.ts style)', () => {
+ const readSource = async () => fs.readFile(SRC_PATH, 'utf-8');
+ const SRC_PATH = path.join(
+ path.dirname(fileURLToPath(import.meta.url)),
+ '../../src/server/api.ts',
+ );
+
+ it('threaded through: parseGrepQuery call, deadline, fileFilter filter, timedOut flag', async () => {
+ const source = await readSource();
+ const grepSection = source.match(/app\.get\('\/api\/grep'[\s\S]*?\n \}\);/);
+ expect(grepSection).not.toBeNull();
+ const section = grepSection![0];
+ expect(section).toContain('parseGrepQuery(');
+ expect(section).toContain('GREP_TIME_BUDGET_MS');
+ expect(section).toMatch(/Date\.now\(\) > deadline/);
+ expect(section).toMatch(/filePath\.toLowerCase\(\)\.includes\(fileFilter\)/);
+ expect(section).toContain('timedOut: true');
+ expect(section).toContain('if (timedOut) break');
+ expect(section).toContain('readOnly: true'); // unchanged read-only DB open
+ });
+});

File diff suppressed because it is too large Load diff

View file

@ -1,728 +0,0 @@
diff --git a/gitnexus/README.md b/gitnexus/README.md
index 6826534c1..12bd96d20 100644
--- a/gitnexus/README.md
+++ b/gitnexus/README.md
@@ -240,7 +240,7 @@ gitnexus analyze --force # Full rebuild: re-parse + graph rebuild + FTS
gitnexus analyze --embeddings # Enable embedding generation (slower, better search)
gitnexus embeddings install # Fetch the optional local embedding stack on demand (--cuda, --force)
gitnexus analyze --skills # Generate repo-specific skill files from detected communities
-gitnexus analyze --skip-agents-md # Preserve custom AGENTS.md/CLAUDE.md gitnexus section edits
+gitnexus analyze --skip-agents-md # Preserve custom AGENTS.md/CLAUDE.md gitnexus section edits (does not skip standard skills; use --skip-skills; community --skills files are unaffected)
gitnexus analyze --skip-skills # Skip installing standard .claude/skills/gitnexus-* skill files
gitnexus analyze --skip-git # Index folders that are not Git repositories
gitnexus analyze --workers <n> # Parse worker pool size (>=1; default: cores-1, capped at 16)
diff --git a/gitnexus/src/cli/ai-context.ts b/gitnexus/src/cli/ai-context.ts
index ca02f2b08..c7b3a934f 100644
--- a/gitnexus/src/cli/ai-context.ts
+++ b/gitnexus/src/cli/ai-context.ts
@@ -11,6 +11,7 @@ import path from 'path';
import { fileURLToPath } from 'url';
import { type GeneratedSkillInfo } from './generated-skill.js';
import { STANDARD_SKILL_CATALOG } from './standard-skills.js';
+import { isEnoent } from './editor-targets.js';
import { logger } from '../core/logger.js';
// ESM equivalent of __dirname
@@ -440,17 +441,84 @@ export async function shouldMirrorSkillsToAgents(repoPath: string): Promise<bool
}
}
+const SKILL_PRESERVE_HINT =
+ 'delete the file to refresh from the bundled template, or pass --skip-skills to skip skill install';
+
+async function readUtf8IfPresent(filePath: string): Promise<string | null> {
+ try {
+ return await fs.readFile(filePath, 'utf-8');
+ } catch (err) {
+ if (isEnoent(err)) return null;
+ throw err;
+ }
+}
+
+function skillBytesDiverge(existing: string | null, bundled: string): boolean {
+ return existing !== null && existing !== bundled;
+}
+
+/** Write bundled skill bytes unless an existing file already differs. */
+async function writeSkillUnlessDivergent(filePath: string, content: string): Promise<boolean> {
+ const existing = await readUtf8IfPresent(filePath);
+ if (skillBytesDiverge(existing, content)) {
+ logger.warn(`Preserved customized skill ${filePath}; ${SKILL_PRESERVE_HINT}.`);
+ return true;
+ }
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
+ await fs.writeFile(filePath, content, 'utf-8');
+ return false;
+}
+
+async function inspectLegacySkillDir(
+ legacyDir: string,
+): Promise<{ nestedExisting: string | null; hasSiblings: boolean } | null> {
+ let entries: string[];
+ try {
+ entries = await fs.readdir(legacyDir);
+ } catch (err) {
+ if (isEnoent(err)) return null;
+ throw err;
+ }
+ const nestedExisting = entries.includes('SKILL.md')
+ ? await fs.readFile(path.join(legacyDir, 'SKILL.md'), 'utf-8')
+ : null;
+ return {
+ nestedExisting,
+ hasSiblings: entries.some((entry) => entry !== 'SKILL.md'),
+ };
+}
+
+function formatSkillInstallLine(
+ prefix: string,
+ total: number,
+ preserved: number,
+ allWrittenSuffix: string,
+ partialSuffix: string,
+): string {
+ if (preserved > 0) {
+ return `${prefix} (${total - preserved} written, ${preserved} ${partialSuffix})`;
+ }
+ return `${prefix} (${total} ${allWrittenSuffix})`;
+}
+
/**
* Install GitNexus skills as direct children of .claude/skills/
* Works natively with Claude Code, Cursor, and GitHub Copilot.
* Mirrored to .agents/skills/ when .agents/ exists.
*/
-async function installSkills(
- repoPath: string,
-): Promise<{ skills: string[]; agentsMirror: boolean }> {
+async function installSkills(repoPath: string): Promise<{
+ skills: string[];
+ agentsMirror: boolean;
+ claudePreserved: number;
+ agentsPreserved: number;
+ legacyPreserved: number;
+}> {
const skillsDir = path.join(repoPath, '.claude', 'skills');
const legacySkillsDir = path.join(skillsDir, 'gitnexus');
const installedSkills: string[] = [];
+ let claudePreserved = 0;
+ let agentsPreserved = 0;
+ let legacyPreserved = 0;
const agentsMirror = await shouldMirrorSkillsToAgents(repoPath);
for (const skill of STANDARD_SKILL_CATALOG.filter(
@@ -460,9 +528,6 @@ async function installSkills(
const skillPath = path.join(skillDir, 'SKILL.md');
try {
- // Create skill directory
- await fs.mkdir(skillDir, { recursive: true });
-
// Try to read from package skills directory
const packageSkillPath = path.join(__dirname, '..', '..', 'skills', `${skill.name}.md`);
let skillContent: string;
@@ -484,14 +549,13 @@ Use GitNexus tools to accomplish this task.
`;
}
- await fs.writeFile(skillPath, skillContent, 'utf-8');
+ if (await writeSkillUnlessDivergent(skillPath, skillContent)) claudePreserved += 1;
// Mirror to .agents/skills/ for agents that read repo-local skills
if (agentsMirror) {
try {
- const agentsSkillDir = path.join(repoPath, '.agents', 'skills', skill.name);
- await fs.mkdir(agentsSkillDir, { recursive: true });
- await fs.writeFile(path.join(agentsSkillDir, 'SKILL.md'), skillContent, 'utf-8');
+ const agentsSkillPath = path.join(repoPath, '.agents', 'skills', skill.name, 'SKILL.md');
+ if (await writeSkillUnlessDivergent(agentsSkillPath, skillContent)) agentsPreserved += 1;
} catch (err) {
logger.warn({ err }, `Warning: Could not mirror skill ${skill.name} to .agents/skills:`);
}
@@ -503,7 +567,20 @@ Use GitNexus tools to accomplish this task.
// deep. Remove only the child owned by this installer; unknown siblings
// under the legacy grouping directory may be user-authored and survive.
try {
- await fs.rm(path.join(legacySkillsDir, skill.name), { recursive: true, force: true });
+ const legacyDir = path.join(legacySkillsDir, skill.name);
+ const nestedSkill = path.join(legacyDir, 'SKILL.md');
+ const leftover = await inspectLegacySkillDir(legacyDir);
+ if (leftover !== null && skillBytesDiverge(leftover.nestedExisting, skillContent)) {
+ logger.warn(`Preserved customized skill ${nestedSkill}; ${SKILL_PRESERVE_HINT}.`);
+ legacyPreserved += 1;
+ } else if (leftover?.hasSiblings) {
+ logger.warn(
+ `Preserved legacy skill directory ${legacyDir} because it contains operator-owned files.`,
+ );
+ legacyPreserved += 1;
+ } else if (leftover !== null) {
+ await fs.rm(legacyDir, { recursive: true, force: true });
+ }
} catch (err) {
logger.warn({ err }, `Warning: Could not remove legacy skill ${skill.name}:`);
}
@@ -513,7 +590,13 @@ Use GitNexus tools to accomplish this task.
}
}
- return { skills: installedSkills, agentsMirror };
+ return {
+ skills: installedSkills,
+ agentsMirror,
+ claudePreserved,
+ agentsPreserved,
+ legacyPreserved,
+ };
}
/**
@@ -592,12 +675,37 @@ export async function generateAIContextFiles(
// Install standard skills directly under .claude/skills/ (unless --skip-skills)
if (!options?.skipSkills) {
- const { skills: installedSkills, agentsMirror } = await installSkills(repoPath);
+ const {
+ skills: installedSkills,
+ agentsMirror,
+ claudePreserved,
+ agentsPreserved,
+ legacyPreserved,
+ } = await installSkills(repoPath);
if (installedSkills.length > 0) {
- createdFiles.push(`.claude/skills/gitnexus-*/ (${installedSkills.length} skills)`);
+ createdFiles.push(
+ formatSkillInstallLine(
+ '.claude/skills/gitnexus-*/',
+ installedSkills.length,
+ claudePreserved,
+ 'skills',
+ 'preserved',
+ ),
+ );
if (agentsMirror) {
createdFiles.push(
- `.agents/skills/gitnexus-*/ (${installedSkills.length} skills mirrored for .agents)`,
+ formatSkillInstallLine(
+ '.agents/skills/gitnexus-*/',
+ installedSkills.length,
+ agentsPreserved,
+ 'skills mirrored for .agents',
+ 'preserved for .agents',
+ ),
+ );
+ }
+ if (legacyPreserved > 0) {
+ createdFiles.push(
+ `.claude/skills/gitnexus/<name>/ (legacy directories preserved: ${legacyPreserved})`,
);
}
}
diff --git a/gitnexus/src/cli/i18n/en.ts b/gitnexus/src/cli/i18n/en.ts
index f5654d1bf..aba8e78f0 100644
--- a/gitnexus/src/cli/i18n/en.ts
+++ b/gitnexus/src/cli/i18n/en.ts
@@ -201,7 +201,7 @@ export const en = {
'help.option.analyze.skills':
'Generate repo-specific skill files from detected communities (no-op when --index-only is also set).',
'help.option.analyze.skipAgentsMd':
- 'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md',
+ 'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md. Does not skip standard skills in .claude/skills or .agents/skills; use --skip-skills for those. Community skills from --skills are unaffected.',
'help.option.analyze.noStats': 'Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md',
'help.option.analyze.selfCommit':
'Auto-commit AGENTS.md/CLAUDE.md changes after analyze (opt-in, off by default). Scoped to only those two files (never `git add -A`); no-ops if neither exists, neither changed, or the repo has no git identity configured.',
diff --git a/gitnexus/src/cli/i18n/zh-CN.ts b/gitnexus/src/cli/i18n/zh-CN.ts
index 4aff202b6..5f3b52040 100644
--- a/gitnexus/src/cli/i18n/zh-CN.ts
+++ b/gitnexus/src/cli/i18n/zh-CN.ts
@@ -188,7 +188,8 @@ export const zhCN = {
'重建时删除现有嵌入。默认情况下,未传 `--embeddings` 的 `analyze` 会保留索引中已有嵌入。',
'help.option.analyze.skills':
'根据检测到的社区生成仓库专属 skill 文件(同时设置 --index-only 时无效)。',
- 'help.option.analyze.skipAgentsMd': '跳过更新 AGENTS.md 和 CLAUDE.md 中的 gitnexus 区块',
+ 'help.option.analyze.skipAgentsMd':
+ '跳过更新 AGENTS.md 和 CLAUDE.md 中的 gitnexus 区块。不会跳过 .claude/skills 或 .agents/skills 下的标准 skill如需跳过那些请使用 --skip-skills。--skills 生成的社区 skill 不受影响。',
'help.option.analyze.noStats': '从 AGENTS.md 和 CLAUDE.md 中省略易变的文件/符号计数',
'help.option.analyze.selfCommit':
'在 analyze 后自动提交 AGENTS.md/CLAUDE.md 的变更(默认关闭,需显式开启)。仅限这两个文件(绝不使用 `git add -A`);若两者均不存在、均未变更,或仓库未配置 git 身份,则不执行任何操作。',
diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts
index 216cc87dd..e53312d62 100644
--- a/gitnexus/src/cli/index.ts
+++ b/gitnexus/src/cli/index.ts
@@ -76,7 +76,10 @@ program
'Generate repo-specific skill files from detected communities ' +
'(no-op when --index-only is also set).',
)
- .option('--skip-agents-md', 'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md')
+ .option(
+ '--skip-agents-md',
+ 'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md. Does not skip standard skills in .claude/skills or .agents/skills; use --skip-skills for those. Community skills from --skills are unaffected.',
+ )
.option(
'--pdg',
'Build the control-flow-graph / PDG substrate (BasicBlock nodes + CFG edges) ' +
diff --git a/gitnexus/src/cli/setup.ts b/gitnexus/src/cli/setup.ts
index 003e548a9..6dd0f9b85 100644
--- a/gitnexus/src/cli/setup.ts
+++ b/gitnexus/src/cli/setup.ts
@@ -1090,14 +1090,30 @@ async function installSkillsTo(targetDir: string): Promise<string[]> {
const skillDir = path.join(targetDir, skillName);
try {
- if (source.isDirectory) {
- const dirSource = path.join(skillsRoot, skillName);
- await copyDirRecursive(dirSource, skillDir);
- } else {
- const flatSource = path.join(skillsRoot, `${skillName}.md`);
- const content = await fs.readFile(flatSource, 'utf-8');
+ const sourceSkillPath = source.isDirectory
+ ? path.join(skillsRoot, skillName, 'SKILL.md')
+ : path.join(skillsRoot, `${skillName}.md`);
+ const destinationSkillPath = path.join(skillDir, 'SKILL.md');
+ const [sourceSkillContent, destinationSkillContent] = await Promise.all([
+ fs.readFile(sourceSkillPath, 'utf-8'),
+ fs.readFile(destinationSkillPath, 'utf-8').catch((err) => {
+ if (!isEnoent(err)) throw err;
+ return null;
+ }),
+ ]);
+
+ const preserved =
+ destinationSkillContent !== null && destinationSkillContent !== sourceSkillContent;
+ if (preserved && !source.isDirectory) {
+ console.log(
+ `[gitnexus] preserved customized skill ${destinationSkillPath}; ` +
+ 'delete the file and rerun setup to refresh it.',
+ );
+ } else if (source.isDirectory) {
+ await copyDirRecursive(path.join(skillsRoot, skillName), skillDir);
+ } else if (!preserved) {
await fs.mkdir(skillDir, { recursive: true });
- await fs.writeFile(path.join(skillDir, 'SKILL.md'), content, 'utf-8');
+ await fs.writeFile(destinationSkillPath, sourceSkillContent, 'utf-8');
}
// A directory superseded by a shipped rename is warned about, never
@@ -1113,7 +1129,7 @@ async function installSkillsTo(targetDir: string): Promise<string[]> {
);
}
}
- installed.push(skillName);
+ if (!preserved) installed.push(skillName);
} catch {
// Source skill not found — skip
}
@@ -1133,9 +1149,23 @@ async function copyDirRecursive(src: string, dest: string): Promise<void> {
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
await copyDirRecursive(srcPath, destPath);
- } else {
- await fs.copyFile(srcPath, destPath);
+ continue;
+ }
+ const [srcBuf, destBuf] = await Promise.all([
+ fs.readFile(srcPath),
+ fs.readFile(destPath).catch((err) => {
+ if (!isEnoent(err)) throw err;
+ return null;
+ }),
+ ]);
+ if (destBuf !== null && !destBuf.equals(srcBuf)) {
+ console.log(
+ `[gitnexus] preserved customized skill ${destPath}; ` +
+ 'delete the file and rerun setup to refresh it.',
+ );
+ continue;
}
+ await fs.writeFile(destPath, srcBuf);
}
}
diff --git a/gitnexus/test/unit/ai-context.test.ts b/gitnexus/test/unit/ai-context.test.ts
index 8e1a08e7c..df610f8e7 100644
--- a/gitnexus/test/unit/ai-context.test.ts
+++ b/gitnexus/test/unit/ai-context.test.ts
@@ -8,6 +8,7 @@ import {
refreshBaseRefLine,
markdownSafeBranch,
} from '../../src/cli/ai-context.js';
+import { _captureLogger } from '../../src/core/logger.js';
describe('generateAIContextFiles', () => {
let tmpDir: string;
@@ -497,7 +498,11 @@ Old content here.
await expect(
fs.access(path.join(dir, '.claude', 'skills', 'gitnexus-exploring', 'SKILL.md')),
).resolves.toBeUndefined();
- await expect(fs.access(legacyKnown)).rejects.toThrow();
+ // Divergent nested SKILL.md is preserved (#3080); only byte-identical
+ // leftovers are still removed.
+ await expect(fs.readFile(path.join(legacyKnown, 'SKILL.md'), 'utf-8')).resolves.toBe(
+ 'legacy',
+ );
await expect(fs.readFile(path.join(legacyUnknown, 'SKILL.md'), 'utf-8')).resolves.toBe(
'custom nested',
);
@@ -538,6 +543,198 @@ Old content here.
}
});
+ it('skipSkills does not remove nested leftover standard skills (#3080 / AE4)', async () => {
+ const skipDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-ai-ctx-skip-nested-'));
+ const skipStorage = path.join(skipDir, '.gitnexus');
+ const nested = path.join(skipDir, '.claude', 'skills', 'gitnexus', 'gitnexus-cli');
+ const bundled = await fs.readFile(
+ path.join(__dirname, '../../skills/gitnexus-cli.md'),
+ 'utf-8',
+ );
+ await fs.mkdir(nested, { recursive: true });
+ await fs.mkdir(skipStorage, { recursive: true });
+ await fs.writeFile(path.join(nested, 'SKILL.md'), bundled, 'utf-8');
+ try {
+ await generateAIContextFiles(skipDir, skipStorage, 'TestProject', { nodes: 1 }, undefined, {
+ skipAgentsMd: true,
+ skipSkills: true,
+ });
+ await expect(fs.readFile(path.join(nested, 'SKILL.md'), 'utf-8')).resolves.toBe(bundled);
+ await expect(
+ fs.access(path.join(skipDir, '.claude', 'skills', 'gitnexus-cli')),
+ ).rejects.toThrow();
+ } finally {
+ await fs.rm(skipDir, { recursive: true, force: true });
+ }
+ });
+
+ it('preserves customized flat SKILL.md under skipAgentsMd (#3080 / AE1)', async () => {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-3080-flat-'));
+ const storage = path.join(dir, '.gitnexus');
+ const cliSkill = path.join(dir, '.claude', 'skills', 'gitnexus-cli', 'SKILL.md');
+ await fs.mkdir(path.dirname(cliSkill), { recursive: true });
+ await fs.mkdir(storage, { recursive: true });
+ await fs.writeFile(cliSkill, 'CUSTOM-COMMITTED-SKILL-3080-cli\n', 'utf-8');
+ const cap = _captureLogger();
+ try {
+ const result = await generateAIContextFiles(
+ dir,
+ storage,
+ 'TestProject',
+ { nodes: 1 },
+ undefined,
+ { skipAgentsMd: true },
+ );
+ expect(result.files).toContain('AGENTS.md (skipped via --skip-agents-md)');
+ expect(result.files.some((f) => f.includes('skipped via --skip-skills'))).toBe(false);
+ await expect(fs.readFile(cliSkill, 'utf-8')).resolves.toBe(
+ 'CUSTOM-COMMITTED-SKILL-3080-cli\n',
+ );
+ const msgs = cap.records().map((r) => r.msg ?? '');
+ expect(msgs.some((m) => m.includes(cliSkill) && m.includes('--skip-skills'))).toBe(true);
+ } finally {
+ cap.restore();
+ await fs.rm(dir, { recursive: true, force: true });
+ }
+ });
+
+ it('creates a missing standard skill from the bundle (#3080 / AE2)', async () => {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-3080-missing-'));
+ const storage = path.join(dir, '.gitnexus');
+ await fs.mkdir(storage, { recursive: true });
+ try {
+ await generateAIContextFiles(dir, storage, 'TestProject', { nodes: 1 }, undefined, {
+ skipAgentsMd: true,
+ });
+ const created = await fs.readFile(
+ path.join(dir, '.claude', 'skills', 'gitnexus-debugging', 'SKILL.md'),
+ 'utf-8',
+ );
+ const bundled = await fs.readFile(
+ path.join(__dirname, '../../skills/gitnexus-debugging.md'),
+ 'utf-8',
+ );
+ expect(created).toBe(bundled);
+ } finally {
+ await fs.rm(dir, { recursive: true, force: true });
+ }
+ });
+
+ it('rewrites a SKILL.md that already matches the current bundle (R2)', async () => {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-3080-ident-'));
+ const storage = path.join(dir, '.gitnexus');
+ const dest = path.join(dir, '.claude', 'skills', 'gitnexus-cli', 'SKILL.md');
+ const bundled = await fs.readFile(
+ path.join(__dirname, '../../skills/gitnexus-cli.md'),
+ 'utf-8',
+ );
+ await fs.mkdir(path.dirname(dest), { recursive: true });
+ await fs.mkdir(storage, { recursive: true });
+ await fs.writeFile(dest, bundled, 'utf-8');
+ const writeSpy = vi.spyOn(fs, 'writeFile');
+ try {
+ await generateAIContextFiles(dir, storage, 'TestProject', { nodes: 1 }, undefined, {
+ skipAgentsMd: true,
+ });
+ await expect(fs.readFile(dest, 'utf-8')).resolves.toBe(bundled);
+ expect(writeSpy).toHaveBeenCalledWith(dest, bundled, 'utf-8');
+ } finally {
+ writeSpy.mockRestore();
+ await fs.rm(dir, { recursive: true, force: true });
+ }
+ });
+
+ it('removes nested leftover when SKILL.md matches the bundle', async () => {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-3080-nested-ident-'));
+ const storage = path.join(dir, '.gitnexus');
+ const nested = path.join(dir, '.claude', 'skills', 'gitnexus', 'gitnexus-cli');
+ const bundled = await fs.readFile(
+ path.join(__dirname, '../../skills/gitnexus-cli.md'),
+ 'utf-8',
+ );
+ await fs.mkdir(nested, { recursive: true });
+ await fs.mkdir(storage, { recursive: true });
+ await fs.writeFile(path.join(nested, 'SKILL.md'), bundled, 'utf-8');
+ try {
+ await generateAIContextFiles(dir, storage, 'TestProject', { nodes: 1 }, undefined, {
+ skipAgentsMd: true,
+ });
+ await expect(fs.access(nested)).rejects.toThrow();
+ } finally {
+ await fs.rm(dir, { recursive: true, force: true });
+ }
+ });
+
+ it('preserves nested leftover siblings even when SKILL.md matches the bundle', async () => {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-3080-nested-sibling-'));
+ const storage = path.join(dir, '.gitnexus');
+ const nested = path.join(dir, '.claude', 'skills', 'gitnexus', 'gitnexus-cli');
+ const bundled = await fs.readFile(
+ path.join(__dirname, '../../skills/gitnexus-cli.md'),
+ 'utf-8',
+ );
+ await fs.mkdir(nested, { recursive: true });
+ await fs.mkdir(storage, { recursive: true });
+ await fs.writeFile(path.join(nested, 'SKILL.md'), bundled, 'utf-8');
+ await fs.writeFile(path.join(nested, 'notes.md'), 'operator notes\n', 'utf-8');
+ const cap = _captureLogger();
+ try {
+ const result = await generateAIContextFiles(
+ dir,
+ storage,
+ 'TestProject',
+ { nodes: 1 },
+ undefined,
+ { skipAgentsMd: true },
+ );
+ await expect(fs.readFile(path.join(nested, 'notes.md'), 'utf-8')).resolves.toBe(
+ 'operator notes\n',
+ );
+ expect(result.files).toContain(
+ '.claude/skills/gitnexus/<name>/ (legacy directories preserved: 1)',
+ );
+ expect(cap.records().some((record) => record.msg?.includes('operator-owned files'))).toBe(
+ true,
+ );
+ } finally {
+ cap.restore();
+ await fs.rm(dir, { recursive: true, force: true });
+ }
+ });
+
+ it('preserves a divergent .agents mirror while writing a missing .claude copy', async () => {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-3080-agents-'));
+ const storage = path.join(dir, '.gitnexus');
+ const agentsSkill = path.join(dir, '.agents', 'skills', 'gitnexus-cli', 'SKILL.md');
+ await fs.mkdir(path.dirname(agentsSkill), { recursive: true });
+ await fs.mkdir(storage, { recursive: true });
+ await fs.writeFile(agentsSkill, 'CUSTOM-AGENTS-MIRROR\n', 'utf-8');
+ try {
+ const result = await generateAIContextFiles(
+ dir,
+ storage,
+ 'TestProject',
+ { nodes: 1 },
+ undefined,
+ {
+ skipAgentsMd: true,
+ },
+ );
+ await expect(fs.readFile(agentsSkill, 'utf-8')).resolves.toBe('CUSTOM-AGENTS-MIRROR\n');
+ const claudeCopy = await fs.readFile(
+ path.join(dir, '.claude', 'skills', 'gitnexus-cli', 'SKILL.md'),
+ 'utf-8',
+ );
+ expect(claudeCopy).not.toBe('CUSTOM-AGENTS-MIRROR\n');
+ expect(claudeCopy.length).toBeGreaterThan(0);
+ expect(result.files).toContain(
+ '.agents/skills/gitnexus-*/ (5 written, 1 preserved for .agents)',
+ );
+ } finally {
+ await fs.rm(dir, { recursive: true, force: true });
+ }
+ });
+
it('mirrors standard skills to .agents/skills/ when .agents/ exists', async () => {
// Some agents prefer repo-local .agents/skills over the global
// ~/.agents/skills install. When the repo contains an .agents/ directory,
diff --git a/gitnexus/test/unit/setup-antigravity.test.ts b/gitnexus/test/unit/setup-antigravity.test.ts
index 80b3b055a..338b75806 100644
--- a/gitnexus/test/unit/setup-antigravity.test.ts
+++ b/gitnexus/test/unit/setup-antigravity.test.ts
@@ -64,6 +64,11 @@ describe('setupAntigravity', () => {
});
};
+ const restoreSkillsRoot = (previous: string | undefined) => {
+ if (previous === undefined) delete process.env.GITNEXUS_TEST_SKILLS_ROOT;
+ else process.env.GITNEXUS_TEST_SKILLS_ROOT = previous;
+ };
+
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
@@ -263,6 +268,7 @@ describe('setupAntigravity', () => {
'---\nname: gitnexus-test\ndescription: fixture\n---\nbody\n',
'utf-8',
);
+ const originalSkillsRoot = process.env.GITNEXUS_TEST_SKILLS_ROOT;
process.env.GITNEXUS_TEST_SKILLS_ROOT = fixtureSkillsRoot;
try {
@@ -278,7 +284,117 @@ describe('setupAntigravity', () => {
fs.access(path.join(skillsDir, 'gitnexus-test', 'SKILL.md')),
).resolves.toBeUndefined();
} finally {
- delete process.env.GITNEXUS_TEST_SKILLS_ROOT;
+ restoreSkillsRoot(originalSkillsRoot);
+ }
+ });
+
+ it('preserves a customized installed skill when setup is rerun', async () => {
+ const fixtureSkillsRoot = path.join(tempHome, 'fixture-skills');
+ const installedSkill = path.join(
+ tempHome,
+ '.gemini',
+ 'antigravity',
+ 'skills',
+ 'gitnexus-test',
+ 'SKILL.md',
+ );
+ await fs.mkdir(fixtureSkillsRoot, { recursive: true });
+ await fs.writeFile(
+ path.join(fixtureSkillsRoot, 'gitnexus-test.md'),
+ '---\nname: gitnexus-test\ndescription: fixture\n---\nbody\n',
+ 'utf-8',
+ );
+ const originalSkillsRoot = process.env.GITNEXUS_TEST_SKILLS_ROOT;
+ process.env.GITNEXUS_TEST_SKILLS_ROOT = fixtureSkillsRoot;
+
+ try {
+ const { setupCommand } = await import('../../src/cli/setup.js');
+ await setupCommand();
+ await fs.writeFile(installedSkill, 'customized by operator\n', 'utf-8');
+
+ await setupCommand();
+
+ await expect(fs.readFile(installedSkill, 'utf-8')).resolves.toBe('customized by operator\n');
+ } finally {
+ restoreSkillsRoot(originalSkillsRoot);
+ }
+ });
+
+ it('preserves customized files inside a directory skill when SKILL.md still matches', async () => {
+ const fixtureSkillsRoot = path.join(tempHome, 'fixture-skills');
+ const skillDir = path.join(tempHome, '.gemini', 'antigravity', 'skills', 'gitnexus-test');
+ const referencePath = path.join(skillDir, 'references', 'note.md');
+ await fs.mkdir(path.join(fixtureSkillsRoot, 'gitnexus-test', 'references'), {
+ recursive: true,
+ });
+ await fs.writeFile(
+ path.join(fixtureSkillsRoot, 'gitnexus-test', 'SKILL.md'),
+ '---\nname: gitnexus-test\ndescription: fixture\n---\nbody\n',
+ 'utf-8',
+ );
+ await fs.writeFile(
+ path.join(fixtureSkillsRoot, 'gitnexus-test', 'references', 'note.md'),
+ 'bundled note\n',
+ 'utf-8',
+ );
+ const originalSkillsRoot = process.env.GITNEXUS_TEST_SKILLS_ROOT;
+ process.env.GITNEXUS_TEST_SKILLS_ROOT = fixtureSkillsRoot;
+
+ try {
+ const { setupCommand } = await import('../../src/cli/setup.js');
+ await setupCommand();
+ await fs.writeFile(referencePath, 'operator note\n', 'utf-8');
+
+ await setupCommand();
+
+ await expect(fs.readFile(referencePath, 'utf-8')).resolves.toBe('operator note\n');
+ await expect(fs.readFile(path.join(skillDir, 'SKILL.md'), 'utf-8')).resolves.toBe(
+ '---\nname: gitnexus-test\ndescription: fixture\n---\nbody\n',
+ );
+ } finally {
+ restoreSkillsRoot(originalSkillsRoot);
+ }
+ });
+
+ it('copies new bundled companions even when SKILL.md was customized', async () => {
+ const fixtureSkillsRoot = path.join(tempHome, 'fixture-skills');
+ const skillDir = path.join(tempHome, '.gemini', 'antigravity', 'skills', 'gitnexus-test');
+ await fs.mkdir(path.join(fixtureSkillsRoot, 'gitnexus-test', 'references'), {
+ recursive: true,
+ });
+ await fs.writeFile(
+ path.join(fixtureSkillsRoot, 'gitnexus-test', 'SKILL.md'),
+ '---\nname: gitnexus-test\ndescription: fixture\n---\nbody\n',
+ 'utf-8',
+ );
+ await fs.writeFile(
+ path.join(fixtureSkillsRoot, 'gitnexus-test', 'references', 'note.md'),
+ 'bundled note\n',
+ 'utf-8',
+ );
+ const originalSkillsRoot = process.env.GITNEXUS_TEST_SKILLS_ROOT;
+ process.env.GITNEXUS_TEST_SKILLS_ROOT = fixtureSkillsRoot;
+
+ try {
+ const { setupCommand } = await import('../../src/cli/setup.js');
+ await setupCommand();
+ await fs.writeFile(path.join(skillDir, 'SKILL.md'), 'customized by operator\n', 'utf-8');
+ await fs.writeFile(
+ path.join(fixtureSkillsRoot, 'gitnexus-test', 'references', 'added.md'),
+ 'new bundled companion\n',
+ 'utf-8',
+ );
+
+ await setupCommand();
+
+ await expect(fs.readFile(path.join(skillDir, 'SKILL.md'), 'utf-8')).resolves.toBe(
+ 'customized by operator\n',
+ );
+ await expect(
+ fs.readFile(path.join(skillDir, 'references', 'added.md'), 'utf-8'),
+ ).resolves.toBe('new bundled companion\n');
+ } finally {
+ restoreSkillsRoot(originalSkillsRoot);
}
});
});
diff --git a/gitnexus/test/unit/skip-git-cli.test.ts b/gitnexus/test/unit/skip-git-cli.test.ts
index 9163e8692..66dc82474 100644
--- a/gitnexus/test/unit/skip-git-cli.test.ts
+++ b/gitnexus/test/unit/skip-git-cli.test.ts
@@ -39,11 +39,15 @@ describe('--skip-git CLI flag', () => {
cwd: path.resolve(__dirname, '../..'),
encoding: 'utf8',
timeout: 10000,
+ env: { ...process.env, GITNEXUS_LANG: 'en' },
});
expect(helpOutput).toContain('--skip-git');
- expect(helpOutput).toContain('--skip-agents-md');
- expect(helpOutput).toContain('--skip-skills');
+ const helpFlat = helpOutput.replace(/\s+/g, ' ');
+ expect(helpFlat).toContain('--skip-agents-md');
+ expect(helpFlat).toContain('Does not skip standard skills in .claude/skills');
+ expect(helpFlat).toContain('Community skills from --skills are unaffected');
+ expect(helpFlat).toContain('--skip-skills');
expect(helpOutput).toContain('directly under .claude/skills/');
expect(helpOutput).toContain('.agents/skills/');
expect(helpOutput).toContain('.claude/skills/gitnexus-area-*');

View file

@ -1,608 +0,0 @@
diff --git a/AGENTS.md b/AGENTS.md
index 251f410c1..05be52af2 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -121,7 +121,8 @@ This project is indexed by GitNexus as **GitNexus** (248612 symbols, 565510 rela
- **MUST analyze graph changes before committing.** Use `detect_changes({scope: "all"})` (MCP) or `node .gitnexus/run.cjs detect-changes --scope all --repo .` (CLI fallback). `partial: true` or `truncated: true` is not a clean check — a zero means unseen, not unaffected; re-run it. For regression review: `detect_changes({scope: "compare", base_ref: "main"})` or `node .gitnexus/run.cjs detect-changes --scope compare --base-ref "main" --repo .`.
- MUST warn on HIGH/CRITICAL `risk` pre-edit; never use `riskSharedAxes` to waive a HIGH/CRITICAL `risk` warning. Compare File/symbol: MCP File omits axes; Graph-RAG expands File.
- **MUST treat `risk: UNKNOWN` as unresolved, not as low.** An empty caller set is not evidence the symbol is unused — it can also mean the callers are not resolvable by the index (plain-object property access, dynamic dispatch, cross-language calls). `impact` pairs `UNKNOWN` with a `riskNote` saying so. Confirm with a text search before treating the symbol as safe to change or delete; do not proceed on the strength of a zero.
-- **MUST use `query({search_query: "concept"})` for concepts/flows, `context({name: "symbolName"})` for a named symbol, or `impact` for blast radius, on read-only callers, dependencies, imports, or execution flow.** Graph first; text search only for empty/`UNKNOWN`/literals.
+- Explore with `query({search_query: "concept"})` for process-grouped flows.
+- Use `context({name: "symbolName"})` for callers, callees, and flows.
- For security review, `explain({target: "fileOrSymbol"})` lists taint findings (source→sink flows; needs `analyze --pdg`).
- For control/data dependence, `pdg_query({mode: "controls", target: "fileOrSymbol"})` answers "under what condition does X run?" (CDG, incl. guard clauses) and `pdg_query({mode: "flows", target, variable})` traces "where does variable Y flow?" (REACHING_DEF). `--pdg` layer.
diff --git a/CLAUDE.md b/CLAUDE.md
index 069163232..10681d819 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -72,7 +72,8 @@ This project is indexed by GitNexus as **GitNexus** (248612 symbols, 565510 rela
- **MUST analyze graph changes before committing.** Use `detect_changes({scope: "all"})` (MCP) or `node .gitnexus/run.cjs detect-changes --scope all --repo .` (CLI fallback). `partial: true` or `truncated: true` is not a clean check — a zero means unseen, not unaffected; re-run it. For regression review: `detect_changes({scope: "compare", base_ref: "main"})` or `node .gitnexus/run.cjs detect-changes --scope compare --base-ref "main" --repo .`.
- MUST warn on HIGH/CRITICAL `risk` pre-edit; never use `riskSharedAxes` to waive a HIGH/CRITICAL `risk` warning. Compare File/symbol: MCP File omits axes; Graph-RAG expands File.
- **MUST treat `risk: UNKNOWN` as unresolved, not as low.** An empty caller set is not evidence the symbol is unused — it can also mean the callers are not resolvable by the index (plain-object property access, dynamic dispatch, cross-language calls). `impact` pairs `UNKNOWN` with a `riskNote` saying so. Confirm with a text search before treating the symbol as safe to change or delete; do not proceed on the strength of a zero.
-- **MUST use `query({search_query: "concept"})` for concepts/flows, `context({name: "symbolName"})` for a named symbol, or `impact` for blast radius, on read-only callers, dependencies, imports, or execution flow.** Graph first; text search only for empty/`UNKNOWN`/literals.
+- Explore with `query({search_query: "concept"})` for process-grouped flows.
+- Use `context({name: "symbolName"})` for callers, callees, and flows.
- For security review, `explain({target: "fileOrSymbol"})` lists taint findings (source→sink flows; needs `analyze --pdg`).
- For control/data dependence, `pdg_query({mode: "controls", target: "fileOrSymbol"})` answers "under what condition does X run?" (CDG, incl. guard clauses) and `pdg_query({mode: "flows", target, variable})` traces "where does variable Y flow?" (REACHING_DEF). `--pdg` layer.
diff --git a/gitnexus/README.md b/gitnexus/README.md
index 6826534c1..76fabd91e 100644
--- a/gitnexus/README.md
+++ b/gitnexus/README.md
@@ -240,7 +240,7 @@ gitnexus analyze --force # Full rebuild: re-parse + graph rebuild + FTS
gitnexus analyze --embeddings # Enable embedding generation (slower, better search)
gitnexus embeddings install # Fetch the optional local embedding stack on demand (--cuda, --force)
gitnexus analyze --skills # Generate repo-specific skill files from detected communities
-gitnexus analyze --skip-agents-md # Preserve custom AGENTS.md/CLAUDE.md gitnexus section edits
+gitnexus analyze --skip-agents-md # Preserve custom AGENTS.md/CLAUDE.md gitnexus section edits (does not skip skills; use --skip-skills)
gitnexus analyze --skip-skills # Skip installing standard .claude/skills/gitnexus-* skill files
gitnexus analyze --skip-git # Index folders that are not Git repositories
gitnexus analyze --workers <n> # Parse worker pool size (>=1; default: cores-1, capped at 16)
diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json
index 1ea3049e6..bff5aac80 100644
--- a/gitnexus/package-lock.json
+++ b/gitnexus/package-lock.json
@@ -14,7 +14,7 @@
"@modelcontextprotocol/sdk": "^1.0.0",
"@scarf/scarf": "^1.4.0",
"busboy": "^1.6.0",
- "chokidar": "^5.0.0",
+ "chokidar": "^4.0.3",
"cli-progress": "^3.12.0",
"commander": "^15.0.0",
"cors": "^2.8.5",
@@ -33,6 +33,7 @@
"node-addon-api": "^8.0.0",
"node-gyp-build": "^4.8.0",
"onnxruntime-common": "^1.26.0",
+ "onnxruntime-node": "1.29.0",
"pandemonium": "^2.4.0",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
@@ -1888,9 +1889,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
- "version": "26.3.0",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-26.3.0.tgz",
- "integrity": "sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw==",
+ "version": "26.2.0",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
+ "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==",
"devOptional": true,
"license": "MIT",
"dependencies": {
@@ -2408,15 +2409,15 @@
}
},
"node_modules/chokidar": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
- "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
+ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"license": "MIT",
"dependencies": {
- "readdirp": "^5.0.0"
+ "readdirp": "^4.0.1"
},
"engines": {
- "node": ">= 20.19.0"
+ "node": ">= 14.16.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
@@ -4719,12 +4720,12 @@
}
},
"node_modules/readdirp": {
- "version": "5.1.1",
- "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz",
- "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==",
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
+ "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"license": "MIT",
"engines": {
- "node": ">= 20.19.0"
+ "node": ">= 14.18.0"
},
"funding": {
"type": "individual",
diff --git a/gitnexus/package.json b/gitnexus/package.json
index 1e15ad9b4..19a9c752f 100644
--- a/gitnexus/package.json
+++ b/gitnexus/package.json
@@ -60,7 +60,7 @@
"@modelcontextprotocol/sdk": "^1.0.0",
"@scarf/scarf": "^1.4.0",
"busboy": "^1.6.0",
- "chokidar": "^5.0.0",
+ "chokidar": "^4.0.3",
"cli-progress": "^3.12.0",
"commander": "^15.0.0",
"cors": "^2.8.5",
diff --git a/gitnexus/src/cli/ai-context.ts b/gitnexus/src/cli/ai-context.ts
index ca02f2b08..bcd6af84e 100644
--- a/gitnexus/src/cli/ai-context.ts
+++ b/gitnexus/src/cli/ai-context.ts
@@ -231,7 +231,8 @@ This project is indexed by GitNexus as **${projectName}**${noStats ? '' : ` (${s
- **MUST analyze graph changes before committing.** Use \`detect_changes({scope: "all"})\` (MCP) or \`${runner} detect-changes --scope all --repo .\` (CLI fallback). \`partial: true\` or \`truncated: true\` is not a clean check — a zero means unseen, not unaffected; re-run it. For regression review: \`detect_changes({scope: "compare", base_ref: ${JSON.stringify(markdownSafeBranch(defaultBranch))}})\` or \`${runner} detect-changes --scope compare --base-ref ${JSON.stringify(markdownSafeBranch(defaultBranch))} --repo .\`.
- MUST warn on HIGH/CRITICAL \`risk\` pre-edit; never use \`riskSharedAxes\` to waive a HIGH/CRITICAL \`risk\` warning. Compare File/symbol: MCP File omits axes; Graph-RAG expands File.
- **MUST treat \`risk: UNKNOWN\` as unresolved, not as low.** An empty caller set is not evidence the symbol is unused — it can also mean the callers are not resolvable by the index (plain-object property access, dynamic dispatch, cross-language calls). \`impact\` pairs \`UNKNOWN\` with a \`riskNote\` saying so. Confirm with a text search before treating the symbol as safe to change or delete; do not proceed on the strength of a zero.
-- **MUST use \`query({search_query: "concept"})\` for concepts/flows, \`context({name: "symbolName"})\` for a named symbol, or \`impact\` for blast radius, on read-only callers, dependencies, imports, or execution flow.** Graph first; text search only for empty/\`UNKNOWN\`/literals.${
+- Explore with \`query({search_query: "concept"})\` for process-grouped flows.
+- Use \`context({name: "symbolName"})\` for callers, callees, and flows.${
hasSpringActuator
? '\n- Spring Actuator runtime evidence is enabled. A Route is authoritative only when `runtimeConfirmed === true`; `runtimeSource` is provenance and may also describe conflicts. Snapshot values are never persisted.'
: ''
@@ -440,6 +441,28 @@ export async function shouldMirrorSkillsToAgents(repoPath: string): Promise<bool
}
}
+const SKILL_PRESERVE_HINT =
+ 'delete the file to refresh from the bundled template, or pass --skip-skills to skip skill install';
+
+async function readUtf8IfPresent(filePath: string): Promise<string | null> {
+ try {
+ return await fs.readFile(filePath, 'utf-8');
+ } catch {
+ return null;
+ }
+}
+
+/** Write bundled skill bytes unless an existing file already differs (#3080). */
+async function writeSkillUnlessDivergent(filePath: string, content: string): Promise<void> {
+ const existing = await readUtf8IfPresent(filePath);
+ if (existing !== null && existing !== content) {
+ logger.warn(`Preserved customized skill ${filePath}; ${SKILL_PRESERVE_HINT}.`);
+ return;
+ }
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
+ await fs.writeFile(filePath, content, 'utf-8');
+}
+
/**
* Install GitNexus skills as direct children of .claude/skills/
* Works natively with Claude Code, Cursor, and GitHub Copilot.
@@ -460,9 +483,6 @@ async function installSkills(
const skillPath = path.join(skillDir, 'SKILL.md');
try {
- // Create skill directory
- await fs.mkdir(skillDir, { recursive: true });
-
// Try to read from package skills directory
const packageSkillPath = path.join(__dirname, '..', '..', 'skills', `${skill.name}.md`);
let skillContent: string;
@@ -484,14 +504,13 @@ Use GitNexus tools to accomplish this task.
`;
}
- await fs.writeFile(skillPath, skillContent, 'utf-8');
+ await writeSkillUnlessDivergent(skillPath, skillContent);
// Mirror to .agents/skills/ for agents that read repo-local skills
if (agentsMirror) {
try {
- const agentsSkillDir = path.join(repoPath, '.agents', 'skills', skill.name);
- await fs.mkdir(agentsSkillDir, { recursive: true });
- await fs.writeFile(path.join(agentsSkillDir, 'SKILL.md'), skillContent, 'utf-8');
+ const agentsSkillPath = path.join(repoPath, '.agents', 'skills', skill.name, 'SKILL.md');
+ await writeSkillUnlessDivergent(agentsSkillPath, skillContent);
} catch (err) {
logger.warn({ err }, `Warning: Could not mirror skill ${skill.name} to .agents/skills:`);
}
@@ -502,8 +521,16 @@ Use GitNexus tools to accomplish this task.
// Previous releases installed these known standard skills one level too
// deep. Remove only the child owned by this installer; unknown siblings
// under the legacy grouping directory may be user-authored and survive.
+ // Skip rm when nested SKILL.md exists and differs from the bundle (#3080).
try {
- await fs.rm(path.join(legacySkillsDir, skill.name), { recursive: true, force: true });
+ const legacyDir = path.join(legacySkillsDir, skill.name);
+ const nestedSkill = path.join(legacyDir, 'SKILL.md');
+ const nestedExisting = await readUtf8IfPresent(nestedSkill);
+ if (nestedExisting !== null && nestedExisting !== skillContent) {
+ logger.warn(`Preserved customized skill ${nestedSkill}; ${SKILL_PRESERVE_HINT}.`);
+ } else {
+ await fs.rm(legacyDir, { recursive: true, force: true });
+ }
} catch (err) {
logger.warn({ err }, `Warning: Could not remove legacy skill ${skill.name}:`);
}
diff --git a/gitnexus/src/cli/i18n/en.ts b/gitnexus/src/cli/i18n/en.ts
index f5654d1bf..17e5cce07 100644
--- a/gitnexus/src/cli/i18n/en.ts
+++ b/gitnexus/src/cli/i18n/en.ts
@@ -201,7 +201,7 @@ export const en = {
'help.option.analyze.skills':
'Generate repo-specific skill files from detected communities (no-op when --index-only is also set).',
'help.option.analyze.skipAgentsMd':
- 'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md',
+ 'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md. Does not skip .claude/skills or .agents/skills; use --skip-skills for those.',
'help.option.analyze.noStats': 'Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md',
'help.option.analyze.selfCommit':
'Auto-commit AGENTS.md/CLAUDE.md changes after analyze (opt-in, off by default). Scoped to only those two files (never `git add -A`); no-ops if neither exists, neither changed, or the repo has no git identity configured.',
diff --git a/gitnexus/src/cli/i18n/zh-CN.ts b/gitnexus/src/cli/i18n/zh-CN.ts
index 4aff202b6..03494edcc 100644
--- a/gitnexus/src/cli/i18n/zh-CN.ts
+++ b/gitnexus/src/cli/i18n/zh-CN.ts
@@ -188,7 +188,8 @@ export const zhCN = {
'重建时删除现有嵌入。默认情况下,未传 `--embeddings` 的 `analyze` 会保留索引中已有嵌入。',
'help.option.analyze.skills':
'根据检测到的社区生成仓库专属 skill 文件(同时设置 --index-only 时无效)。',
- 'help.option.analyze.skipAgentsMd': '跳过更新 AGENTS.md 和 CLAUDE.md 中的 gitnexus 区块',
+ 'help.option.analyze.skipAgentsMd':
+ '跳过更新 AGENTS.md 和 CLAUDE.md 中的 gitnexus 区块。不会跳过 .claude/skills 或 .agents/skills如需跳过技能安装请使用 --skip-skills。',
'help.option.analyze.noStats': '从 AGENTS.md 和 CLAUDE.md 中省略易变的文件/符号计数',
'help.option.analyze.selfCommit':
'在 analyze 后自动提交 AGENTS.md/CLAUDE.md 的变更(默认关闭,需显式开启)。仅限这两个文件(绝不使用 `git add -A`);若两者均不存在、均未变更,或仓库未配置 git 身份,则不执行任何操作。',
diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts
index 216cc87dd..5ac2b6336 100644
--- a/gitnexus/src/cli/index.ts
+++ b/gitnexus/src/cli/index.ts
@@ -76,7 +76,10 @@ program
'Generate repo-specific skill files from detected communities ' +
'(no-op when --index-only is also set).',
)
- .option('--skip-agents-md', 'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md')
+ .option(
+ '--skip-agents-md',
+ 'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md. Does not skip .claude/skills or .agents/skills; use --skip-skills for those.',
+ )
.option(
'--pdg',
'Build the control-flow-graph / PDG substrate (BasicBlock nodes + CFG edges) ' +
diff --git a/gitnexus/test/integration/spring-actuator-kotlin-runtime-pipeline.test.ts b/gitnexus/test/integration/spring-actuator-kotlin-runtime-pipeline.test.ts
index 4c21bd90c..ac1925565 100644
--- a/gitnexus/test/integration/spring-actuator-kotlin-runtime-pipeline.test.ts
+++ b/gitnexus/test/integration/spring-actuator-kotlin-runtime-pipeline.test.ts
@@ -5,7 +5,7 @@ import { afterEach, describe, expect, it } from 'vitest';
import type { GraphNode } from 'gitnexus-shared';
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
-const SECRET_ENV_VALUE = 'KOTLIN_ACTUATOR_ENV_CANARY_2418';
+const SECRET = 'KOTLIN_ACTUATOR_SECRET_2418';
function writeFixture(root: string, relativePath: string, content: string): void {
const target = path.join(root, relativePath);
@@ -240,11 +240,9 @@ class KotlinProperties {
contexts: {
application: {
positiveMatches: {
- 'com.example.KotlinConfig#billingService': [{ message: SECRET_ENV_VALUE }],
- 'com.example.KotlinController$Companion#companionHandler': [
- { message: SECRET_ENV_VALUE },
- ],
- 'com.example.NamedHolder$Factory#namedHandler': [{ message: SECRET_ENV_VALUE }],
+ 'com.example.KotlinConfig#billingService': [{ message: SECRET }],
+ 'com.example.KotlinController$Companion#companionHandler': [{ message: SECRET }],
+ 'com.example.NamedHolder$Factory#namedHandler': [{ message: SECRET }],
},
negativeMatches: {},
},
@@ -256,7 +254,7 @@ class KotlinProperties {
beans: {
kotlin: {
prefix: 'app.kotlin',
- inputs: { url: { value: SECRET_ENV_VALUE, origin: SECRET_ENV_VALUE } },
+ inputs: { url: { value: SECRET, origin: SECRET } },
},
},
},
@@ -265,10 +263,10 @@ class KotlinProperties {
writeJson(repo, 'actuator/env.json', {
propertySources: [
{
- name: SECRET_ENV_VALUE,
+ name: SECRET,
properties: {
- 'app.kotlin.url': { value: SECRET_ENV_VALUE, origin: SECRET_ENV_VALUE },
- 'app.kotlin.password': { value: SECRET_ENV_VALUE, origin: SECRET_ENV_VALUE },
+ 'app.kotlin.url': { value: SECRET, origin: SECRET },
+ 'app.kotlin.password': { value: SECRET, origin: SECRET },
},
},
],
@@ -382,7 +380,7 @@ describe('Spring Boot Actuator Kotlin runtime enrichment', () => {
'Spring Actuator env runtime-confirmed',
);
- expect(JSON.stringify([...result.graph.iterNodes()])).not.toContain(SECRET_ENV_VALUE);
+ expect(JSON.stringify([...result.graph.iterNodes()])).not.toContain(SECRET);
expect(nodes.some((node) => String(node.properties.filePath).includes('/actuator/'))).toBe(
false,
);
diff --git a/gitnexus/test/unit/ai-context-read-path-must.test.ts b/gitnexus/test/unit/ai-context-read-path-must.test.ts
deleted file mode 100644
index 20c39d145..000000000
--- a/gitnexus/test/unit/ai-context-read-path-must.test.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import { generateGitNexusContent } from '../../src/cli/ai-context.js';
-
-// Regression guard for #3076. The Explore/Use Always-Do lines were advisory, so
-// read-only sessions had no MUST to call query/context/impact. The replacement
-// bullet is not hasPdg-gated (unlike pdg_query) and is not nested in an
-// edit/commit/rename-only sentence.
-describe('generateGitNexusContent emits a read-path MUST (#3076)', () => {
- const stats = { nodes: 50, edges: 100, processes: 5 };
- const mustBullet =
- '- **MUST use `query({search_query: "concept"})` for concepts/flows, `context({name: "symbolName"})` for a named symbol, or `impact` for blast radius, on read-only callers, dependencies, imports, or execution flow.** Graph first; text search only for empty/`UNKNOWN`/literals.';
-
- function alwaysDoSection(content: string): string {
- return content.slice(content.indexOf('## Always Do'), content.indexOf('## Never Do'));
- }
-
- function assertNoAdvisoryExploreUse(content: string): void {
- expect(content).not.toMatch(/Explore\s+with/);
- expect(content).not.toMatch(/Use\s+`context\(\{name:/);
- expect(alwaysDoSection(content)).not.toMatch(/^- [^\n]*Explore/m);
- }
-
- it.each([true, false])(
- 'renders the MUST and drops Explore/Use bullets when hasPdg=%s',
- (hasPdg) => {
- const content = generateGitNexusContent('ReadPathProject', stats, { hasPdg });
- expect(alwaysDoSection(content)).toContain(`\n${mustBullet}\n`);
- assertNoAdvisoryExploreUse(content);
- if (hasPdg) {
- expect(content).toContain('pdg_query');
- }
- },
- );
-
- it('keeps the MUST beside the Spring Actuator Always-Do line', () => {
- const content = generateGitNexusContent('SpringProject', stats, { hasSpringActuator: true });
- expect(alwaysDoSection(content)).toContain(
- `${mustBullet}\n- Spring Actuator runtime evidence is enabled`,
- );
- assertNoAdvisoryExploreUse(content);
- });
-
- it('keeps pdg_query gated on hasPdg while the read-path MUST stays always-emitted', () => {
- const withoutPdg = generateGitNexusContent('PlainProject', stats);
- expect(alwaysDoSection(withoutPdg)).toContain(`\n${mustBullet}\n`);
- expect(withoutPdg).not.toContain('pdg_query');
- });
-});
diff --git a/gitnexus/test/unit/ai-context.test.ts b/gitnexus/test/unit/ai-context.test.ts
index 8e1a08e7c..e711f3ea0 100644
--- a/gitnexus/test/unit/ai-context.test.ts
+++ b/gitnexus/test/unit/ai-context.test.ts
@@ -8,6 +8,7 @@ import {
refreshBaseRefLine,
markdownSafeBranch,
} from '../../src/cli/ai-context.js';
+import { _captureLogger } from '../../src/core/logger.js';
describe('generateAIContextFiles', () => {
let tmpDir: string;
@@ -497,7 +498,11 @@ Old content here.
await expect(
fs.access(path.join(dir, '.claude', 'skills', 'gitnexus-exploring', 'SKILL.md')),
).resolves.toBeUndefined();
- await expect(fs.access(legacyKnown)).rejects.toThrow();
+ // Divergent nested SKILL.md is preserved (#3080); only byte-identical
+ // leftovers are still removed.
+ await expect(fs.readFile(path.join(legacyKnown, 'SKILL.md'), 'utf-8')).resolves.toBe(
+ 'legacy',
+ );
await expect(fs.readFile(path.join(legacyUnknown, 'SKILL.md'), 'utf-8')).resolves.toBe(
'custom nested',
);
@@ -538,6 +543,137 @@ Old content here.
}
});
+ it('skipSkills does not remove nested leftover standard skills (#3080 / AE4)', async () => {
+ const skipDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-ai-ctx-skip-nested-'));
+ const skipStorage = path.join(skipDir, '.gitnexus');
+ const nested = path.join(skipDir, '.claude', 'skills', 'gitnexus', 'gitnexus-cli');
+ await fs.mkdir(nested, { recursive: true });
+ await fs.mkdir(skipStorage, { recursive: true });
+ await fs.writeFile(path.join(nested, 'SKILL.md'), 'CUSTOM-NESTED', 'utf-8');
+ try {
+ await generateAIContextFiles(skipDir, skipStorage, 'TestProject', { nodes: 1 }, undefined, {
+ skipAgentsMd: true,
+ skipSkills: true,
+ });
+ await expect(fs.readFile(path.join(nested, 'SKILL.md'), 'utf-8')).resolves.toBe(
+ 'CUSTOM-NESTED',
+ );
+ } finally {
+ await fs.rm(skipDir, { recursive: true, force: true });
+ }
+ });
+
+ it('preserves customized flat SKILL.md under skipAgentsMd (#3080 / AE1)', async () => {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-3080-flat-'));
+ const storage = path.join(dir, '.gitnexus');
+ const cliSkill = path.join(dir, '.claude', 'skills', 'gitnexus-cli', 'SKILL.md');
+ await fs.mkdir(path.dirname(cliSkill), { recursive: true });
+ await fs.mkdir(storage, { recursive: true });
+ await fs.writeFile(cliSkill, 'CUSTOM-COMMITTED-SKILL-3080-cli\n', 'utf-8');
+ const cap = _captureLogger();
+ try {
+ const result = await generateAIContextFiles(
+ dir,
+ storage,
+ 'TestProject',
+ { nodes: 1 },
+ undefined,
+ { skipAgentsMd: true },
+ );
+ expect(result.files).toContain('AGENTS.md (skipped via --skip-agents-md)');
+ expect(result.files.some((f) => f.includes('skipped via --skip-skills'))).toBe(false);
+ await expect(fs.readFile(cliSkill, 'utf-8')).resolves.toBe(
+ 'CUSTOM-COMMITTED-SKILL-3080-cli\n',
+ );
+ const msgs = cap.records().map((r) => r.msg ?? '');
+ expect(msgs.some((m) => m.includes(cliSkill) && m.includes('--skip-skills'))).toBe(true);
+ } finally {
+ cap.restore();
+ await fs.rm(dir, { recursive: true, force: true });
+ }
+ });
+
+ it('creates a missing standard skill from the bundle (#3080 / AE2)', async () => {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-3080-missing-'));
+ const storage = path.join(dir, '.gitnexus');
+ await fs.mkdir(storage, { recursive: true });
+ try {
+ await generateAIContextFiles(dir, storage, 'TestProject', { nodes: 1 }, undefined, {
+ skipAgentsMd: true,
+ });
+ const created = await fs.readFile(
+ path.join(dir, '.claude', 'skills', 'gitnexus-debugging', 'SKILL.md'),
+ 'utf-8',
+ );
+ const bundled = await fs.readFile(
+ path.join(__dirname, '../../skills/gitnexus-debugging.md'),
+ 'utf-8',
+ );
+ expect(created).toBe(bundled);
+ } finally {
+ await fs.rm(dir, { recursive: true, force: true });
+ }
+ });
+
+ it('rewrites a SKILL.md that already matches the current bundle (R2)', async () => {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-3080-ident-'));
+ const storage = path.join(dir, '.gitnexus');
+ const dest = path.join(dir, '.claude', 'skills', 'gitnexus-cli', 'SKILL.md');
+ const bundled = await fs.readFile(path.join(__dirname, '../../skills/gitnexus-cli.md'), 'utf-8');
+ await fs.mkdir(path.dirname(dest), { recursive: true });
+ await fs.mkdir(storage, { recursive: true });
+ await fs.writeFile(dest, bundled, 'utf-8');
+ try {
+ await generateAIContextFiles(dir, storage, 'TestProject', { nodes: 1 }, undefined, {
+ skipAgentsMd: true,
+ });
+ await expect(fs.readFile(dest, 'utf-8')).resolves.toBe(bundled);
+ } finally {
+ await fs.rm(dir, { recursive: true, force: true });
+ }
+ });
+
+ it('removes nested leftover when SKILL.md matches the bundle', async () => {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-3080-nested-ident-'));
+ const storage = path.join(dir, '.gitnexus');
+ const nested = path.join(dir, '.claude', 'skills', 'gitnexus', 'gitnexus-cli');
+ const bundled = await fs.readFile(path.join(__dirname, '../../skills/gitnexus-cli.md'), 'utf-8');
+ await fs.mkdir(nested, { recursive: true });
+ await fs.mkdir(storage, { recursive: true });
+ await fs.writeFile(path.join(nested, 'SKILL.md'), bundled, 'utf-8');
+ try {
+ await generateAIContextFiles(dir, storage, 'TestProject', { nodes: 1 }, undefined, {
+ skipAgentsMd: true,
+ });
+ await expect(fs.access(nested)).rejects.toThrow();
+ } finally {
+ await fs.rm(dir, { recursive: true, force: true });
+ }
+ });
+
+ it('preserves a divergent .agents mirror while writing a missing .claude copy', async () => {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-3080-agents-'));
+ const storage = path.join(dir, '.gitnexus');
+ const agentsSkill = path.join(dir, '.agents', 'skills', 'gitnexus-cli', 'SKILL.md');
+ await fs.mkdir(path.dirname(agentsSkill), { recursive: true });
+ await fs.mkdir(storage, { recursive: true });
+ await fs.writeFile(agentsSkill, 'CUSTOM-AGENTS-MIRROR\n', 'utf-8');
+ try {
+ await generateAIContextFiles(dir, storage, 'TestProject', { nodes: 1 }, undefined, {
+ skipAgentsMd: true,
+ });
+ await expect(fs.readFile(agentsSkill, 'utf-8')).resolves.toBe('CUSTOM-AGENTS-MIRROR\n');
+ const claudeCopy = await fs.readFile(
+ path.join(dir, '.claude', 'skills', 'gitnexus-cli', 'SKILL.md'),
+ 'utf-8',
+ );
+ expect(claudeCopy).not.toBe('CUSTOM-AGENTS-MIRROR\n');
+ expect(claudeCopy.length).toBeGreaterThan(0);
+ } finally {
+ await fs.rm(dir, { recursive: true, force: true });
+ }
+ });
+
it('mirrors standard skills to .agents/skills/ when .agents/ exists', async () => {
// Some agents prefer repo-local .agents/skills over the global
// ~/.agents/skills install. When the repo contains an .agents/ directory,
diff --git a/gitnexus/test/unit/shipped-skills-sync.test.ts b/gitnexus/test/unit/shipped-skills-sync.test.ts
index 936f4c2af..984037e4c 100644
--- a/gitnexus/test/unit/shipped-skills-sync.test.ts
+++ b/gitnexus/test/unit/shipped-skills-sync.test.ts
@@ -330,10 +330,6 @@ function extractManagedBlock(file: string): string {
return match![1];
}
-function alwaysDoSection(block: string): string {
- return block.slice(block.indexOf('## Always Do'), block.indexOf('## Never Do'));
-}
-
// The `risk: UNKNOWN` Always-Do bullet and its Never-Do clause were hand-added
// INSIDE the machine-managed region instead of living in the template, so a
// real analyze run silently deleted them on regeneration — twice (#2856's
@@ -361,33 +357,18 @@ describe('root AGENTS.md / CLAUDE.md managed block keeps the risk: UNKNOWN polic
for (const fragment of REQUIRED_FRAGMENTS) expect(block).toContain(fragment);
});
- it.each(['AGENTS.md', 'CLAUDE.md'])(
- '%s Always-Do pins the read-path MUST as its own bullet (#3076)',
- (file) => {
- const alwaysDo = alwaysDoSection(extractManagedBlock(file));
- expect(alwaysDo).toMatch(/^- \*\*MUST use `query\(\{search_query: "concept"\}\)`/m);
- expect(alwaysDo).toContain('Graph first');
- expect(alwaysDo).toContain('text search only for empty/');
- expect(alwaysDo).not.toMatch(/Explore\s+with/);
- expect(alwaysDo).not.toMatch(/Use\s+`context\(\{name:/);
- expect(alwaysDo).not.toMatch(/^- [^\n]*Explore/m);
- },
- );
-
it.each(['AGENTS.md', 'CLAUDE.md'])(
"%s managed block's Always Do / Never Do bullet counts do not drop below the known floor",
(file) => {
const block = extractManagedBlock(file);
- const alwaysDo = alwaysDoSection(block);
- const neverDoSection = block.slice(block.indexOf('## Never Do'));
- const ungated = (alwaysDo.match(/^- .+/gm) ?? []).filter(
- (line) => !line.includes('pdg_query'),
+ const alwaysDoSection = block.slice(
+ block.indexOf('## Always Do'),
+ block.indexOf('## Never Do'),
);
- // Six Always-Do bullets are not hasPdg-gated after #3076. pdg_query is
- // extra when the committed block was generated with --pdg. Counting
- // ungated bullets (not total >= 6) fails if the read-path MUST leaves
- // Always-Do while pdg_query keeps the old slack.
- expect(ungated).toHaveLength(6);
+ const neverDoSection = block.slice(block.indexOf('## Never Do'));
+ // 7 Always-Do bullets are unconditional; an 8th (pdg_query) only
+ // appears when the index was built with --pdg, so the floor is 7, not 8.
+ expect((alwaysDoSection.match(/^- /gm) || []).length).toBeGreaterThanOrEqual(7);
// Never Do never varies with hasPdg — exactly 4 today, so 4 is the floor.
expect((neverDoSection.match(/^- NEVER /gm) || []).length).toBeGreaterThanOrEqual(4);
},
diff --git a/gitnexus/test/unit/skip-git-cli.test.ts b/gitnexus/test/unit/skip-git-cli.test.ts
index 9163e8692..ef7ec0b72 100644
--- a/gitnexus/test/unit/skip-git-cli.test.ts
+++ b/gitnexus/test/unit/skip-git-cli.test.ts
@@ -42,8 +42,10 @@ describe('--skip-git CLI flag', () => {
});
expect(helpOutput).toContain('--skip-git');
- expect(helpOutput).toContain('--skip-agents-md');
- expect(helpOutput).toContain('--skip-skills');
+ const helpFlat = helpOutput.replace(/\s+/g, ' ');
+ expect(helpFlat).toContain('--skip-agents-md');
+ expect(helpFlat).toContain('Does not skip .claude/skills');
+ expect(helpFlat).toContain('--skip-skills');
expect(helpOutput).toContain('directly under .claude/skills/');
expect(helpOutput).toContain('.agents/skills/');
expect(helpOutput).toContain('.claude/skills/gitnexus-area-*');

File diff suppressed because it is too large Load diff

View file

@ -1,380 +0,0 @@
diff --git a/GUARDRAILS.md b/GUARDRAILS.md
index f34cf79d5..354be789c 100644
--- a/GUARDRAILS.md
+++ b/GUARDRAILS.md
@@ -37,7 +37,7 @@ Format: **Trigger → Instruction → Reason**. Append new Signs when the same m
### Index seems corrupt or "incremental" is misbehaving
- **Trigger:** `analyze` produces unexpected results, or `incrementalInProgress` is set in the index metadata (`.gitnexus/gitnexus.json` / legacy `meta.json`), or the index is in a half-state after a crash.
-- **Do:** `npx gitnexus analyze --force` to rebuild from scratch. The dirty-flag check forces this automatically when a previous incremental run didn't complete cleanly, but `--force` is the manual escape hatch. A dirty-flag recovery rebuild parks the interrupted run's sidecars beside the DB as `lbug.wal.dirty-recovery` / `lbug.shadow.dirty-recovery` for post-mortem debugging — harmless, and removable with `npx gitnexus clean --lbug-sidecars`. Safe to delete the `.gitnexus/parse-cache/` directory (and any legacy `.gitnexus/parse-cache.json`) at any time — content-addressed, will be regenerated.
+- **Do:** `npx gitnexus analyze --force` to rebuild the graph and FTS indexes. This may reuse unchanged parser output; when debugging parser/capture changes, use `npx gitnexus analyze --no-parse-cache` to rebuild that output too. The dirty-flag check forces the graph rebuild automatically when a previous incremental run didn't complete cleanly. A dirty-flag recovery rebuild parks the interrupted run's sidecars beside the DB as `lbug.wal.dirty-recovery` / `lbug.shadow.dirty-recovery` for post-mortem debugging — harmless, and removable with `npx gitnexus clean --lbug-sidecars`. Safe to delete the `.gitnexus/parse-cache/` directory (and any legacy `.gitnexus/parse-cache.json`) at any time — content-addressed, will be regenerated.
- **Why:** Incremental writeback is selective DB row replacement; if the on-disk state is inconsistent for any reason, a full rebuild is the cheapest path back to a known-good index.
### Embeddings vanished after analyze
diff --git a/README.md b/README.md
index 8b25f8e09..f2714bf89 100644
--- a/README.md
+++ b/README.md
@@ -436,7 +436,8 @@ The token may be set in the shell, `.env.local`, or `.env` in the working direct
<summary><strong>All <code>analyze</code> flags</strong></summary>
```bash
-gitnexus analyze --force # Full rebuild: re-parse + graph rebuild + FTS rebuild
+gitnexus analyze --force # Full graph + FTS rebuild (reuses unchanged parser output)
+gitnexus analyze --no-parse-cache # Full rebuild that re-parses every source file
gitnexus analyze --repair-fts # Fast path: rebuild/verify only FTS indexes on existing index data
gitnexus analyze --skills # Generate repo-specific skill files from detected communities
gitnexus analyze --skip-embeddings # Skip embedding generation (faster)
diff --git a/gitnexus/README.md b/gitnexus/README.md
index f50871b71..c871a853d 100644
--- a/gitnexus/README.md
+++ b/gitnexus/README.md
@@ -236,7 +236,8 @@ gitnexus uninstall # Preview removal of GitNexus MCP/skills/hooks
gitnexus analyze [path] # Index a repository (or update stale index)
gitnexus analyze [path] --watch # Watch local files and serialize incremental refreshes
gitnexus analyze --repair-fts # Fast path: rebuild/verify only FTS indexes on existing index data
-gitnexus analyze --force # Full rebuild: re-parse + graph rebuild + FTS rebuild
+gitnexus analyze --force # Rebuild graph + FTS; may reuse unchanged parser output
+gitnexus analyze --no-parse-cache # Re-parse every source file, then rebuild graph + FTS
gitnexus analyze --embeddings # Enable embedding generation (slower, better search)
gitnexus embeddings install # Fetch the optional local embedding stack on demand (--cuda, --force)
gitnexus analyze --skills # Generate repo-specific skill files from detected communities
diff --git a/gitnexus/src/cli/analyze-options.ts b/gitnexus/src/cli/analyze-options.ts
index 646fe8494..36f8925c5 100644
--- a/gitnexus/src/cli/analyze-options.ts
+++ b/gitnexus/src/cli/analyze-options.ts
@@ -20,6 +20,8 @@ export interface AnalyzeOptions {
/** Watch quiet period in milliseconds. */
debounce?: string;
force?: boolean;
+ /** Commander negated flag: false only when --no-parse-cache is passed. */
+ parseCache?: boolean;
repairFts?: boolean;
/**
* Embedding generation toggle. Commander parses `--embeddings [limit]` as:
diff --git a/gitnexus/src/cli/analyze-watch.ts b/gitnexus/src/cli/analyze-watch.ts
index 172a3a090..189728963 100644
--- a/gitnexus/src/cli/analyze-watch.ts
+++ b/gitnexus/src/cli/analyze-watch.ts
@@ -104,6 +104,7 @@ export async function resolveWatchOptions(
const merged = mergeAnalyzeOptions(cli, config);
const unsupported = [
['--force', cli.force],
+ ['--no-parse-cache', cli.parseCache === false],
['--repair-fts', cli.repairFts],
['--embeddings', cli.embeddings],
['--drop-embeddings', cli.dropEmbeddings],
diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts
index f2a06059e..52930c54a 100644
--- a/gitnexus/src/cli/analyze.ts
+++ b/gitnexus/src/cli/analyze.ts
@@ -1184,10 +1184,10 @@ const analyzeCommandImpl = async (
}
}
- if (options.repairFts && options.force) {
+ if (options.repairFts && (options.force || options.parseCache === false)) {
cliError(
- ' Cannot combine `--repair-fts` with `--force`. ' +
- 'Use `--repair-fts` for fast FTS-only repair, or `--force` for a full rebuild.\n',
+ ' Cannot combine `--repair-fts` with a full rebuild. ' +
+ 'Use `--repair-fts` alone for fast FTS-only repair.\n',
);
process.exitCode = 1;
return;
@@ -1345,10 +1345,11 @@ const analyzeCommandImpl = async (
const skipAgentsMd = skipAll || options.skipAgentsMd;
const skipSkills = skipAll || options.skipSkills;
const runOptions = {
- // Pipeline re-index — OR'd with --skills because skill generation
- // needs a fresh pipelineResult. Has no bearing on the registry
- // collision guard (see allowDuplicateName below).
- force: options.force || options.skills,
+ // Pipeline re-index — OR'd with --skills because skill generation needs
+ // a fresh pipelineResult, and with --no-parse-cache because bypassing
+ // parser output is meaningful only when the pipeline runs.
+ force: options.force || options.skills || options.parseCache === false,
+ useParseCache: options.parseCache !== false,
repairFts: options.repairFts,
embeddings: embeddingsEnabled,
embeddingsNodeLimit,
diff --git a/gitnexus/src/cli/help-i18n.ts b/gitnexus/src/cli/help-i18n.ts
index 283e99832..01a44a206 100644
--- a/gitnexus/src/cli/help-i18n.ts
+++ b/gitnexus/src/cli/help-i18n.ts
@@ -53,6 +53,7 @@ const OPTION_DESCRIPTION_KEYS = {
'|-V, --version': 'help.option.version',
'setup|-c, --coding-agent <agents>': 'help.option.setup.codingAgent',
'analyze|-f, --force': 'help.option.analyze.force',
+ 'analyze|--no-parse-cache': 'help.option.analyze.noParseCache',
'analyze|--repair-fts': 'help.option.analyze.repairFts',
'analyze|--embeddings [limit]': 'help.option.analyze.embeddings',
'analyze|--drop-embeddings': 'help.option.analyze.dropEmbeddings',
diff --git a/gitnexus/src/cli/i18n/en.ts b/gitnexus/src/cli/i18n/en.ts
index 58904c48f..73c2b7362 100644
--- a/gitnexus/src/cli/i18n/en.ts
+++ b/gitnexus/src/cli/i18n/en.ts
@@ -204,7 +204,9 @@ export const en = {
'help.command.group.contracts.description': 'Inspect Contract Registry',
'help.option.setup.codingAgent':
'Configure only these coding agents (comma-separated or repeatable)',
- 'help.option.analyze.force': 'Force full re-index even if up to date',
+ 'help.option.analyze.force': 'Force graph and FTS rebuild; unchanged parser output may be reused',
+ 'help.option.analyze.noParseCache':
+ 'Re-parse every source file instead of replaying cached parser output',
'help.option.analyze.repairFts': 'Repair/rebuild search FTS indexes without full re-analysis',
'help.option.analyze.embeddings':
'Enable embedding generation for semantic search (off by default). Optional [limit] overrides the 50,000-node safety cap; pass 0 to disable the cap entirely.',
diff --git a/gitnexus/src/cli/i18n/zh-CN.ts b/gitnexus/src/cli/i18n/zh-CN.ts
index de9249cc4..e18d0d3de 100644
--- a/gitnexus/src/cli/i18n/zh-CN.ts
+++ b/gitnexus/src/cli/i18n/zh-CN.ts
@@ -192,7 +192,8 @@ export const zhCN = {
'help.command.group.query.description': '跨仓库组所有仓库搜索执行流程',
'help.command.group.contracts.description': '查看 Contract Registry',
'help.option.setup.codingAgent': '仅配置这些编码代理(逗号分隔或重复传入)',
- 'help.option.analyze.force': '即使已是最新也强制完整重建索引',
+ 'help.option.analyze.force': '强制重建图和 FTS未更改的解析器输出可能被复用',
+ 'help.option.analyze.noParseCache': '重新解析每个源文件,不重放缓存的解析器输出',
'help.option.analyze.repairFts': '修复/重建搜索 FTS 索引,不执行完整重新分析',
'help.option.analyze.embeddings':
'启用语义搜索的嵌入生成(默认关闭)。可选 [limit] 覆盖 50,000 节点安全上限;传 0 可完全禁用上限。',
diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts
index 6851044c0..ee575e47d 100644
--- a/gitnexus/src/cli/index.ts
+++ b/gitnexus/src/cli/index.ts
@@ -75,7 +75,11 @@ program
.description('Index a repository (full analysis)')
.option('--watch', 'Keep the index current with serialized incremental refreshes')
.option('--debounce <ms>', 'Watch quiet period before refreshing (default: 300 milliseconds)')
- .option('-f, --force', 'Force full re-index even if up to date')
+ .option('-f, --force', 'Force graph and FTS rebuild; unchanged parser output may be reused')
+ .option(
+ '--no-parse-cache',
+ 'Re-parse every source file instead of replaying cached parser output',
+ )
.option('--repair-fts', 'Repair/rebuild search FTS indexes without full re-analysis')
.option(
'--embeddings [limit]',
diff --git a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts
index f9cbbd47b..b69c09eb7 100644
--- a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts
+++ b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts
@@ -457,6 +457,8 @@ export async function runChunkedParseAndResolve(
usedWorkerPool: boolean;
/** Files dispatched to parser workers after parse-cache lookup. */
reparsedFileCount: number;
+ /** Files restored from parse-cache chunks without parser-worker dispatch. */
+ parseCacheHitFileCount: number;
/** Worker-produced ParsedFile artifacts aggregated across chunks.
* Threaded into scope-resolution as a re-extract cache so the warm-
* cache analyze run can skip the dominant `extractParsedFile` cost
@@ -752,6 +754,7 @@ export async function runChunkedParseAndResolve(
: new Map<string, ReadonlySet<string>>();
let chunkCacheHits = 0;
let chunkCacheMisses = 0;
+ let parseCacheHitFileCount = 0;
let reparsedFileCount = 0;
try {
@@ -1044,6 +1047,7 @@ export async function runChunkedParseAndResolve(
pendingWorkerChunk = null;
}
chunkCacheHits++;
+ parseCacheHitFileCount += chunkFiles.length;
const chunkWorkerData = mergeChunkResults(graph, symbolTable, cachedRaw, exportedTypeMap);
if (isDev) {
logger.info(
@@ -1611,6 +1615,7 @@ export async function runChunkedParseAndResolve(
// is intentionally measured at dispatch time rather than inferred from
// the git/hash diff.
reparsedFileCount,
+ parseCacheHitFileCount,
// Per-file ParsedFile artifacts produced by workers' calls to
// `extractParsedFile`. Consumed by scope-resolution as a re-extraction
// cache: when the file's ParsedFile is here, scope-resolution skips its own
diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts
index ac1b120e8..8ee94e265 100644
--- a/gitnexus/src/core/ingestion/pipeline.ts
+++ b/gitnexus/src/core/ingestion/pipeline.ts
@@ -411,13 +411,19 @@ export const runPipelineFromRepo = async (
}
// Extract final results for the PipelineResult contract
- const { totalFiles, usedWorkerPool, reparsedFileCount, unavailableScopeLanguageFiles } =
- getPhaseOutput<{
- totalFiles: number;
- usedWorkerPool: boolean;
- reparsedFileCount: number;
- unavailableScopeLanguageFiles: number;
- }>(results, 'parse');
+ const {
+ totalFiles,
+ usedWorkerPool,
+ reparsedFileCount,
+ parseCacheHitFileCount,
+ unavailableScopeLanguageFiles,
+ } = getPhaseOutput<{
+ totalFiles: number;
+ usedWorkerPool: boolean;
+ reparsedFileCount: number;
+ parseCacheHitFileCount: number;
+ unavailableScopeLanguageFiles: number;
+ }>(results, 'parse');
let communityResult: CommunitiesOutput['communityResult'] | undefined;
let processResult: ProcessesOutput['processResult'] | undefined;
@@ -470,6 +476,7 @@ export const runPipelineFromRepo = async (
undecidedSatisfaction,
usedWorkerPool,
reparsedFileCount,
+ parseCacheHitFileCount,
scopeExtractionFailures,
unavailableScopeLanguageFiles,
pdgEmitManifest,
diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts
index 2dfd80b93..989e452de 100644
--- a/gitnexus/src/core/run-analyze.ts
+++ b/gitnexus/src/core/run-analyze.ts
@@ -338,6 +338,8 @@ export interface AnalyzeOptions {
* bypass. See `allowDuplicateName` below.
*/
force?: boolean;
+ /** Reuse content-addressed parser output. Defaults to true. */
+ useParseCache?: boolean;
/** Repair only search indexes without re-running full parsing/indexing. */
repairFts?: boolean;
/** Emit per-index FTS create logs. */
@@ -1964,11 +1966,24 @@ async function runFullAnalysisInner(
}
// ── Load incremental parse cache ──────────────────────────────────
- // Content-addressed: safe to reuse across `--force` runs (chunks whose
- // file contents haven't changed produce identical worker output).
+ // Content-addressed: safe to reuse across `--force` runs when the parser
+ // implementation is unchanged. Developers iterating on capture/query code
+ // can opt out so unchanged source files are parsed again (#3152).
// Loaded into a single ParseCache object that the pipeline mutates
// in-place (cache hits leave entries unchanged; misses add new ones).
- const parseCache = await loadParseCache(storagePath);
+ // Keep storagePath so cold runs retain disk-backed ParsedFile offload and
+ // rewrite the sibling durable store. Empty readable-key state prevents any
+ // parse-cache or ParsedFile shard from the previous generation being replayed.
+ const parseCache =
+ options.useParseCache === false
+ ? {
+ version: PARSE_CACHE_VERSION,
+ entries: new Map(),
+ usedKeys: new Set<string>(),
+ storagePath,
+ onDiskKeys: new Set<string>(),
+ }
+ : await loadParseCache(storagePath);
// Streamed structural emit (#2680). Resolved ONCE, so the pipeline flag and
// the CSV-dir resolution below cannot disagree — and resolved HERE, not at
@@ -2054,6 +2069,16 @@ async function runFullAnalysisInner(
},
);
+ if (options.force && (pipelineResult.parseCacheHitFileCount ?? 0) > 0) {
+ log(
+ `--force rebuilt the graph and FTS while reusing cached parser output for ` +
+ `${pipelineResult.parseCacheHitFileCount} file(s) ` +
+ `(parse cache ${PARSE_CACHE_VERSION}). ` +
+ `For same-version capture/query development changes, increment SCHEMA_BUMP in ` +
+ `src/storage/parse-cache.ts to invalidate parser output.`,
+ );
+ }
+
// ── Phase 2: LadybugDB (6085%) ──────────────────────────────────
progress('lbug', 60, 'Loading into LadybugDB...');
diff --git a/gitnexus/src/types/pipeline.ts b/gitnexus/src/types/pipeline.ts
index 960517416..d665a7acf 100644
--- a/gitnexus/src/types/pipeline.ts
+++ b/gitnexus/src/types/pipeline.ts
@@ -54,6 +54,8 @@ export interface PipelineResult {
usedWorkerPool: boolean;
/** Files actually dispatched to parser workers after parse-cache lookup. */
reparsedFileCount: number;
+ /** Files restored from parse-cache chunks without parser-worker dispatch. */
+ parseCacheHitFileCount?: number;
/** Files omitted from scope-resolution while the rest of analysis continued. */
scopeExtractionFailures: readonly string[];
/** Files scope resolution could not inspect because their parser was unavailable. */
diff --git a/gitnexus/test/unit/analyze-no-stats-bridge.test.ts b/gitnexus/test/unit/analyze-no-stats-bridge.test.ts
index 93cac4d8a..bab7eb100 100644
--- a/gitnexus/test/unit/analyze-no-stats-bridge.test.ts
+++ b/gitnexus/test/unit/analyze-no-stats-bridge.test.ts
@@ -137,6 +137,25 @@ describe('analyzeCommand commander → runFullAnalysis noStats bridge (#1477)',
expect(opts.repairFts).toBe(true);
});
+ it('maps --no-parse-cache to a cold parser run', async () => {
+ const { analyzeCommand } = await import('../../src/cli/analyze.js');
+
+ await analyzeCommand(undefined, { parseCache: false });
+
+ const opts = runFullAnalysisMock.mock.calls[0][1];
+ expect(opts.useParseCache).toBe(false);
+ expect(opts.force).toBe(true);
+ });
+
+ it('reuses parser output by default', async () => {
+ const { analyzeCommand } = await import('../../src/cli/analyze.js');
+
+ await analyzeCommand(undefined, {});
+
+ const opts = runFullAnalysisMock.mock.calls[0][1];
+ expect(opts.useParseCache).toBe(true);
+ });
+
it('rejects combining --repair-fts with --force', async () => {
const { analyzeCommand } = await import('../../src/cli/analyze.js');
@@ -144,11 +163,20 @@ describe('analyzeCommand commander → runFullAnalysis noStats bridge (#1477)',
expect(process.exitCode).toBe(1);
expect(cliErrorMock).toHaveBeenCalledWith(
- expect.stringMatching(/cannot combine `--repair-fts` with `--force`/i),
+ expect.stringMatching(/cannot combine `--repair-fts` with a full rebuild/i),
);
expect(runFullAnalysisMock).not.toHaveBeenCalled();
});
+ it('rejects combining --repair-fts with --no-parse-cache', async () => {
+ const { analyzeCommand } = await import('../../src/cli/analyze.js');
+
+ await analyzeCommand(undefined, { repairFts: true, parseCache: false });
+
+ expect(process.exitCode).toBe(1);
+ expect(runFullAnalysisMock).not.toHaveBeenCalled();
+ });
+
it('passes stats:false as noStats to generateAIContextFiles on the --skills regeneration path (#1477)', async () => {
runFullAnalysisMock.mockResolvedValueOnce({
repoName: 'repo',
diff --git a/gitnexus/test/unit/incremental-orchestration.test.ts b/gitnexus/test/unit/incremental-orchestration.test.ts
index 547f6417d..8a719cef9 100644
--- a/gitnexus/test/unit/incremental-orchestration.test.ts
+++ b/gitnexus/test/unit/incremental-orchestration.test.ts
@@ -663,12 +663,17 @@ describe('runFullAnalysis — incremental orchestration', () => {
);
expect(steady.alreadyUpToDate).toBe(true);
+ const forceLogs: string[] = [];
const forcedSteady = await runFullAnalysis(
repo.dbPath,
{ skipAgentsMd: true, force: true },
- { onProgress: () => {} },
+ { onProgress: () => {}, onLog: (message) => forceLogs.push(message) },
);
expect(forcedSteady.alreadyUpToDate).toBeUndefined();
+ expect(forceLogs.join('\n')).toContain(
+ '--force rebuilt the graph and FTS while reusing cached parser output',
+ );
+ expect(forceLogs.join('\n')).toContain('increment SCHEMA_BUMP');
expect(
await readActuatorSnapshotLeakRows(repo.dbPath, `${runtimeInput}/env.json`, secretValue),
).toEqual([]);

View file

@ -216,56 +216,63 @@ def score_review(
tp = len(pairs)
fp = len(actual) - tp
fn = len(expected) - tp
precision = tp / (tp + fp) if tp + fp else 1.0
recall = tp / (tp + fn) if tp + fn else 1.0
f1 = 2 * precision * recall / (precision + recall) if precision + recall else 0.0
precision = tp / (tp + fp) if tp + fp else None
recall = tp / (tp + fn) if tp + fn else None
f1 = (
2 * precision * recall / (precision + recall)
if precision is not None and recall is not None and precision + recall
else None
)
expected_weight = sum(SEVERITY_WEIGHT[item.severity] for item in expected)
matched_weight = sum(SEVERITY_WEIGHT[expected[e].severity] for _, e in pairs)
fp_weight = sum(SEVERITY_WEIGHT[actual[a].severity] for a in range(len(actual)) if a not in matched_actual)
weighted_precision = matched_weight / (matched_weight + fp_weight) if matched_weight + fp_weight else 1.0
weighted_recall = matched_weight / expected_weight if expected_weight else 1.0
weighted_precision = matched_weight / (matched_weight + fp_weight) if matched_weight + fp_weight else None
weighted_recall = matched_weight / expected_weight if expected_weight else None
weighted_f1 = (
2 * weighted_precision * weighted_recall / (weighted_precision + weighted_recall)
if weighted_precision + weighted_recall
else 0.0
if weighted_precision is not None
and weighted_recall is not None
and weighted_precision + weighted_recall
else None
)
blockers = [index for index, item in enumerate(expected) if item.severity in BLOCKING_SEVERITIES]
blocker_recall = sum(index in matched_expected for index in blockers) / len(blockers) if blockers else 1.0
blocker_recall = sum(index in matched_expected for index in blockers) / len(blockers) if blockers else None
severity_accuracy = (
sum(actual[a].severity == expected[e].severity for a, e in pairs) / tp if tp else (1.0 if not expected else 0.0)
sum(actual[a].severity == expected[e].severity for a, e in pairs) / tp if tp else None
)
category_accuracy = (
sum(actual[a].category == expected[e].category for a, e in pairs) / tp if tp else (1.0 if not expected else 0.0)
sum(actual[a].category == expected[e].category for a, e in pairs) / tp if tp else None
)
grounded = (
sum(bool(item.path and item.line > 0 and item.evidence.strip()) for item in actual) / len(actual)
if actual
else None
)
grounded = sum(
bool(item.path and item.line > 0 and item.evidence.strip()) for item in actual
) / len(actual) if actual else 1.0
correct_verdict = (not expected and verdict == "approve") or (
bool(expected)
and verdict == ("request_changes" if any(item.severity in BLOCKING_SEVERITIES for item in expected) else "comment")
)
def rounded(value: float | None) -> float | None:
return None if value is None else round(value, 6)
return {
"true_positives": tp,
"false_positives": fp,
"false_negatives": fn,
"precision": round(precision, 6),
"recall": round(recall, 6),
"f1": round(f1, 6),
"weighted_precision": round(weighted_precision, 6),
"weighted_recall": round(weighted_recall, 6),
"weighted_f1": round(weighted_f1, 6),
"blocker_recall": round(blocker_recall, 6),
"severity_accuracy": round(severity_accuracy, 6),
"category_accuracy": round(category_accuracy, 6),
"grounded_evidence": round(grounded, 6),
"precision": rounded(precision),
"recall": rounded(recall),
"f1": rounded(f1),
"weighted_precision": rounded(weighted_precision),
"weighted_recall": rounded(weighted_recall),
"weighted_f1": rounded(weighted_f1),
"blocker_recall": rounded(blocker_recall),
"severity_accuracy": rounded(severity_accuracy),
"category_accuracy": rounded(category_accuracy),
"grounded_evidence": rounded(grounded),
"verdict_correct": correct_verdict,
"clean_control": not expected,
"matched_expected_ids": sorted(expected[index].finding_id for index in matched_expected),
"missed_expected_ids": sorted(
expected[index].finding_id for index in range(len(expected)) if index not in matched_expected
),
"clean_pass": not expected and not actual and verdict == "approve",
"score_finite": all(
math.isfinite(float(value))
value is None or math.isfinite(float(value))
for key, value in {
"precision": precision,
"recall": recall,

View file

@ -1221,10 +1221,12 @@ def aggregate(records: list[dict[str, Any]]) -> dict[str, Any]:
)
if any("review_weighted_f1" in record for record in valid):
for metric in review_metrics:
values = [record[metric] for record in valid if metric in record]
values = [record[metric] for record in valid if record.get(metric) is not None]
out[metric] = statistics.median(values) if values and len(values) == len(valid) else None
controls = [record["review_clean_control"] for record in valid if "review_clean_control" in record]
out["review_clean_control"] = all(controls) if controls and len(controls) == len(valid) else None
clean_passes = [record["review_clean_pass"] for record in valid if "review_clean_pass" in record]
out["review_clean_pass"] = all(clean_passes) if clean_passes and len(clean_passes) == len(valid) else None
return out
@ -1272,6 +1274,10 @@ def _cost_cell(value: Any) -> str:
return "n/a" if value is None else f"{value:.4f}"
def _review_metric_cell(value: Any) -> str:
return "n/a" if value is None else f"{value:.3f}"
def render_report(results: dict[str, dict[str, dict[str, Any]]]) -> str:
"""results: {task_id: {arm: aggregate}} → markdown report."""
lines = [
@ -1315,7 +1321,7 @@ def render_report(results: dict[str, dict[str, dict[str, Any]]]) -> str:
f"| {_na(s['cost_usd'])} | {s['duration_s']} | — | — | — |"
)
lines.append("")
if any(agg.get("review_weighted_f1") is not None for arms in results.values() for agg in arms.values()):
if any("review_clean_control" in agg for arms in results.values() for agg in arms.values()):
lines.extend(
[
"## Review quality",
@ -1326,14 +1332,16 @@ def render_report(results: dict[str, dict[str, dict[str, Any]]]) -> str:
)
for task_id, arms in results.items():
for arm, agg in arms.items():
if agg.get("review_weighted_f1") is None:
if "review_clean_control" not in agg:
continue
lines.append(
f"| {task_id} | {arm} | {agg['review_true_positives']:.1f} "
f"| {agg['review_false_positives']:.1f} | {agg['review_false_negatives']:.1f} "
f"| {agg['review_precision']:.3f} | {agg['review_recall']:.3f} "
f"| {agg['review_blocker_recall']:.3f} | {agg['review_weighted_f1']:.3f} "
f"| {agg['review_grounded_evidence']:.3f} |"
f"| {_review_metric_cell(agg['review_precision'])} "
f"| {_review_metric_cell(agg['review_recall'])} "
f"| {_review_metric_cell(agg['review_blocker_recall'])} "
f"| {_review_metric_cell(agg['review_weighted_f1'])} "
f"| {_review_metric_cell(agg['review_grounded_evidence'])} |"
)
lines.append("")
all_aggs = [agg for arms in results.values() for agg in arms.values()]

View file

@ -1,130 +1,76 @@
# Read-only differential review corpus. Each case checks out an immutable
# historical base, applies the visible PR patch, and then freezes the checkout.
# Hidden labels are captured before sanitization and never enter a model mount.
# Exact-head historical PR corpus. Hidden labels are captured by the harness
# before clone sanitization and never enter a reviewer mount.
tasks:
- id: review-pr-3124-defect
- &review_case
id: review-pr-2718-defect
class: review-defect
repo: ~/GitNexus
ref: 9f82ffd6bfe59270c6cdec0e2f3314038f4a7faf
sandbox_copy: [eval/workflow_bench/review_cases/pr-3124-defect.patch]
setup: >-
git apply eval/workflow_bench/review_cases/pr-3124-defect.patch &&
rm -rf eval/workflow_bench
prompt: >-
Review the historical pre-feedback snapshot of
https://github.com/abhigyanpatwari/GitNexus/pull/3124.
Report only actionable defects introduced by the local diff.
ref: ff86ccf1e79cd7e4175da437ae8aeaf67b64aaa1
sandbox_copy: [eval/workflow_bench/review_cases/pr-2718-defect.patch]
setup: git apply eval/workflow_bench/review_cases/pr-2718-defect.patch && rm -rf eval/workflow_bench
prompt: Review the historical snapshot of https://github.com/abhigyanpatwari/GitNexus/pull/2718. Report only actionable defects introduced by the local diff.
verify: test -s review-output.json
oracle:
command: test -s review-output.json
files:
- source: review-pr-3124-defect.labels.json
target: review-labels.json
sandbox_dependencies: &review_dependencies
- source: node_modules
target: node_modules
- source: gitnexus/node_modules
target: gitnexus/node_modules
- source: gitnexus-shared/node_modules
target: gitnexus-shared/node_modules
files: [{source: review-pr-2718-defect.labels.json, target: review-labels.json}]
sandbox_dependencies: &deps
- {source: node_modules, target: node_modules}
- {source: gitnexus/node_modules, target: gitnexus/node_modules}
- {source: gitnexus-shared/node_modules, target: gitnexus-shared/node_modules}
- id: review-pr-3153-defect
class: review-defect
repo: ~/GitNexus
ref: 3c2b14aff59791be0a000160b35f3c5e8f2997a0
sandbox_copy: [eval/workflow_bench/review_cases/pr-3153-defect.patch]
setup: >-
git apply eval/workflow_bench/review_cases/pr-3153-defect.patch &&
rm -rf eval/workflow_bench
prompt: >-
Review the historical pre-feedback snapshot of
https://github.com/abhigyanpatwari/GitNexus/pull/3153.
Report only actionable defects introduced by the local diff.
verify: test -s review-output.json
- <<: *review_case
id: review-pr-2794-defect
ref: 911151e2304f298a995fcc69c738ad2c6db9393a
sandbox_copy: [eval/workflow_bench/review_cases/pr-2794-defect.patch]
setup: git apply eval/workflow_bench/review_cases/pr-2794-defect.patch && rm -rf eval/workflow_bench
prompt: Review the historical snapshot of https://github.com/abhigyanpatwari/GitNexus/pull/2794. Report only actionable defects introduced by the local diff.
oracle:
command: test -s review-output.json
files:
- source: review-pr-3153-defect.labels.json
target: review-labels.json
sandbox_dependencies: *review_dependencies
files: [{source: review-pr-2794-defect.labels.json, target: review-labels.json}]
sandbox_dependencies: *deps
- id: review-pr-3109-defect
class: review-defect
repo: ~/GitNexus
ref: 4aa6bddd0a78135136d29d8440eb29613097f616
sandbox_copy: [eval/workflow_bench/review_cases/pr-3109-defect.patch]
setup: >-
git apply eval/workflow_bench/review_cases/pr-3109-defect.patch &&
rm -rf eval/workflow_bench
prompt: >-
Review the historical pre-feedback snapshot of
https://github.com/abhigyanpatwari/GitNexus/pull/3109.
Report only actionable defects introduced by the local diff.
verify: test -s review-output.json
- <<: *review_case
id: review-pr-2108-defect
ref: 3a4247ec36b5ad86b1123d3bbce8183a643f7434
sandbox_copy: [eval/workflow_bench/review_cases/pr-2108-defect.patch]
setup: git apply eval/workflow_bench/review_cases/pr-2108-defect.patch && rm -rf eval/workflow_bench
prompt: Review the historical snapshot of https://github.com/abhigyanpatwari/GitNexus/pull/2108. Report only actionable defects introduced by the local diff.
oracle:
command: test -s review-output.json
files:
- source: review-pr-3109-defect.labels.json
target: review-labels.json
sandbox_dependencies: *review_dependencies
files: [{source: review-pr-2108-defect.labels.json, target: review-labels.json}]
sandbox_dependencies: *deps
- id: review-pr-3111-defect
class: review-defect
repo: ~/GitNexus
ref: 3aa62be717a579d4644364d91fdd50c1b8b5c286
sandbox_copy: [eval/workflow_bench/review_cases/pr-3111-defect.patch]
setup: >-
git apply eval/workflow_bench/review_cases/pr-3111-defect.patch &&
rm -rf eval/workflow_bench
prompt: >-
Review the historical pre-feedback snapshot of
https://github.com/abhigyanpatwari/GitNexus/pull/3111.
Report only actionable defects introduced by the local diff.
verify: test -s review-output.json
- <<: *review_case
id: review-pr-2258-defect
ref: 78b4077d8acc86f1b0c32e41012174d484e81f12
sandbox_copy: [eval/workflow_bench/review_cases/pr-2258-defect.patch]
setup: git apply eval/workflow_bench/review_cases/pr-2258-defect.patch && rm -rf eval/workflow_bench
prompt: Review the historical snapshot of https://github.com/abhigyanpatwari/GitNexus/pull/2258. Report only actionable defects introduced by the local diff.
oracle:
command: test -s review-output.json
files:
- source: review-pr-3111-defect.labels.json
target: review-labels.json
sandbox_dependencies: *review_dependencies
files: [{source: review-pr-2258-defect.labels.json, target: review-labels.json}]
sandbox_dependencies: *deps
- id: review-pr-3124-clean
- <<: *review_case
id: review-pr-2258-clean
class: review-clean
repo: ~/GitNexus
ref: 9f82ffd6bfe59270c6cdec0e2f3314038f4a7faf
sandbox_copy: [eval/workflow_bench/review_cases/pr-3124-clean.patch]
setup: >-
git apply eval/workflow_bench/review_cases/pr-3124-clean.patch &&
rm -rf eval/workflow_bench
prompt: >-
Review this historical snapshot of
https://github.com/abhigyanpatwari/GitNexus/pull/3124.
Report only actionable defects introduced by the local diff.
verify: test -s review-output.json
ref: 78b4077d8acc86f1b0c32e41012174d484e81f12
sandbox_copy: [eval/workflow_bench/review_cases/pr-2258-clean.patch]
setup: git apply eval/workflow_bench/review_cases/pr-2258-clean.patch && rm -rf eval/workflow_bench
prompt: Review this historical snapshot of https://github.com/abhigyanpatwari/GitNexus/pull/2258. Report only actionable defects introduced by the local diff.
oracle:
command: test -s review-output.json
files:
- source: review-pr-3124-clean.labels.json
target: review-labels.json
sandbox_dependencies: *review_dependencies
files: [{source: review-pr-2258-clean.labels.json, target: review-labels.json}]
sandbox_dependencies: *deps
- id: review-pr-3153-clean
- <<: *review_case
id: review-pr-2773-clean
class: review-clean
repo: ~/GitNexus
ref: 3c2b14aff59791be0a000160b35f3c5e8f2997a0
sandbox_copy: [eval/workflow_bench/review_cases/pr-3153-clean.patch]
setup: >-
git apply eval/workflow_bench/review_cases/pr-3153-clean.patch &&
rm -rf eval/workflow_bench
prompt: >-
Review this historical snapshot of
https://github.com/abhigyanpatwari/GitNexus/pull/3153.
Report only actionable defects introduced by the local diff.
verify: test -s review-output.json
ref: 84f584449de02376a8ffc096dceac2e8f732cab5
sandbox_copy: [eval/workflow_bench/review_cases/pr-2773-clean.patch]
setup: git apply eval/workflow_bench/review_cases/pr-2773-clean.patch && rm -rf eval/workflow_bench
prompt: Review this historical snapshot of https://github.com/abhigyanpatwari/GitNexus/pull/2773. Report only actionable defects introduced by the local diff.
oracle:
command: test -s review-output.json
files:
- source: review-pr-3153-clean.labels.json
target: review-labels.json
sandbox_dependencies: *review_dependencies
files: [{source: review-pr-2773-clean.labels.json, target: review-labels.json}]
sandbox_dependencies: *deps