mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-07 08:26:11 +00:00
fix(eval): align review metrics and corpus evidence
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
6925fb344d
commit
9047bf00a5
32 changed files with 5529 additions and 13132 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"schema_version":1,"findings":[]}
|
||||
|
|
@ -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"}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"schema_version":1,"findings":[]}
|
||||
|
|
@ -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"}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
{
|
||||
"schema_version": 1,
|
||||
"findings": []
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
{
|
||||
"schema_version": 1,
|
||||
"findings": []
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
845
eval/workflow_bench/review_cases/pr-2108-defect.patch
Normal file
845
eval/workflow_bench/review_cases/pr-2108-defect.patch
Normal 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');
|
||||
453
eval/workflow_bench/review_cases/pr-2258-clean.patch
Normal file
453
eval/workflow_bench/review_cases/pr-2258-clean.patch
Normal 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",
|
||||
414
eval/workflow_bench/review_cases/pr-2258-defect.patch
Normal file
414
eval/workflow_bench/review_cases/pr-2258-defect.patch
Normal 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",
|
||||
1096
eval/workflow_bench/review_cases/pr-2718-defect.patch
Normal file
1096
eval/workflow_bench/review_cases/pr-2718-defect.patch
Normal file
File diff suppressed because it is too large
Load diff
1725
eval/workflow_bench/review_cases/pr-2773-clean.patch
Normal file
1725
eval/workflow_bench/review_cases/pr-2773-clean.patch
Normal file
File diff suppressed because it is too large
Load diff
817
eval/workflow_bench/review_cases/pr-2794-defect.patch
Normal file
817
eval/workflow_bench/review_cases/pr-2794-defect.patch
Normal 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 array’s 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();
|
||||
+ });
|
||||
+});
|
||||
|
|
@ -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
|
|
@ -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-*');
|
||||
|
|
@ -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
|
|
@ -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 (60–85%) ──────────────────────────────────
|
||||
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([]);
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue