diff --git a/eval/tests/test_review_scoring.py b/eval/tests/test_review_scoring.py index 55e2139fc..3a81a8bed 100644 --- a/eval/tests/test_review_scoring.py +++ b/eval/tests/test_review_scoring.py @@ -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 diff --git a/eval/tests/test_workflow_bench_evolution.py b/eval/tests/test_workflow_bench_evolution.py index 0cee4fb45..c04489388 100644 --- a/eval/tests/test_workflow_bench_evolution.py +++ b/eval/tests/test_workflow_bench_evolution.py @@ -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", diff --git a/eval/workflow_bench/evolution.py b/eval/workflow_bench/evolution.py index 5322ed4e5..d5e6bcd3c 100644 --- a/eval/workflow_bench/evolution.py +++ b/eval/workflow_bench/evolution.py @@ -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: diff --git a/eval/workflow_bench/evolve.py b/eval/workflow_bench/evolve.py index 08d855b8a..c1b95e133 100644 --- a/eval/workflow_bench/evolve.py +++ b/eval/workflow_bench/evolve.py @@ -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) diff --git a/eval/workflow_bench/oracles/review-pr-2108-defect.labels.json b/eval/workflow_bench/oracles/review-pr-2108-defect.labels.json new file mode 100644 index 000000000..c4a94ffc4 --- /dev/null +++ b/eval/workflow_bench/oracles/review-pr-2108-defect.labels.json @@ -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"} + ] +} diff --git a/eval/workflow_bench/oracles/review-pr-2258-clean.labels.json b/eval/workflow_bench/oracles/review-pr-2258-clean.labels.json new file mode 100644 index 000000000..7926623c9 --- /dev/null +++ b/eval/workflow_bench/oracles/review-pr-2258-clean.labels.json @@ -0,0 +1 @@ +{"schema_version":1,"findings":[]} diff --git a/eval/workflow_bench/oracles/review-pr-2258-defect.labels.json b/eval/workflow_bench/oracles/review-pr-2258-defect.labels.json new file mode 100644 index 000000000..8f2d14514 --- /dev/null +++ b/eval/workflow_bench/oracles/review-pr-2258-defect.labels.json @@ -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"} + ] +} diff --git a/eval/workflow_bench/oracles/review-pr-2718-defect.labels.json b/eval/workflow_bench/oracles/review-pr-2718-defect.labels.json new file mode 100644 index 000000000..dc0765648 --- /dev/null +++ b/eval/workflow_bench/oracles/review-pr-2718-defect.labels.json @@ -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"} + ] +} diff --git a/eval/workflow_bench/oracles/review-pr-2773-clean.labels.json b/eval/workflow_bench/oracles/review-pr-2773-clean.labels.json new file mode 100644 index 000000000..7926623c9 --- /dev/null +++ b/eval/workflow_bench/oracles/review-pr-2773-clean.labels.json @@ -0,0 +1 @@ +{"schema_version":1,"findings":[]} diff --git a/eval/workflow_bench/oracles/review-pr-2794-defect.labels.json b/eval/workflow_bench/oracles/review-pr-2794-defect.labels.json new file mode 100644 index 000000000..51b64eff1 --- /dev/null +++ b/eval/workflow_bench/oracles/review-pr-2794-defect.labels.json @@ -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"} + ] +} diff --git a/eval/workflow_bench/oracles/review-pr-3109-defect.labels.json b/eval/workflow_bench/oracles/review-pr-3109-defect.labels.json deleted file mode 100644 index 4ef7541b8..000000000 --- a/eval/workflow_bench/oracles/review-pr-3109-defect.labels.json +++ /dev/null @@ -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" - } - ] -} diff --git a/eval/workflow_bench/oracles/review-pr-3111-defect.labels.json b/eval/workflow_bench/oracles/review-pr-3111-defect.labels.json deleted file mode 100644 index 868864274..000000000 --- a/eval/workflow_bench/oracles/review-pr-3111-defect.labels.json +++ /dev/null @@ -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" - } - ] -} diff --git a/eval/workflow_bench/oracles/review-pr-3124-clean.labels.json b/eval/workflow_bench/oracles/review-pr-3124-clean.labels.json deleted file mode 100644 index 0c4e81e9e..000000000 --- a/eval/workflow_bench/oracles/review-pr-3124-clean.labels.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "schema_version": 1, - "findings": [] -} diff --git a/eval/workflow_bench/oracles/review-pr-3124-defect.labels.json b/eval/workflow_bench/oracles/review-pr-3124-defect.labels.json deleted file mode 100644 index 4141aee96..000000000 --- a/eval/workflow_bench/oracles/review-pr-3124-defect.labels.json +++ /dev/null @@ -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" - } - ] -} diff --git a/eval/workflow_bench/oracles/review-pr-3153-clean.labels.json b/eval/workflow_bench/oracles/review-pr-3153-clean.labels.json deleted file mode 100644 index 0c4e81e9e..000000000 --- a/eval/workflow_bench/oracles/review-pr-3153-clean.labels.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "schema_version": 1, - "findings": [] -} diff --git a/eval/workflow_bench/oracles/review-pr-3153-defect.labels.json b/eval/workflow_bench/oracles/review-pr-3153-defect.labels.json deleted file mode 100644 index 3e9d1cc61..000000000 --- a/eval/workflow_bench/oracles/review-pr-3153-defect.labels.json +++ /dev/null @@ -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" - } - ] -} diff --git a/eval/workflow_bench/review_cases/manifest.json b/eval/workflow_bench/review_cases/manifest.json index 732c271fb..a2fc875be 100644 --- a/eval/workflow_bench/review_cases/manifest.json +++ b/eval/workflow_bench/review_cases/manifest.json @@ -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"} ] } diff --git a/eval/workflow_bench/review_cases/pr-2108-defect.patch b/eval/workflow_bench/review_cases/pr-2108-defect.patch new file mode 100644 index 000000000..31da9b553 --- /dev/null +++ b/eval/workflow_bench/review_cases/pr-2108-defect.patch @@ -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 — 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 `). 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 ?? ''}`); + } +@@ -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 | 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 { + 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(); ++ const cohesionByNode = new Map(); ++ const contentByNode = new Map(); ++ ++ // 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'); diff --git a/eval/workflow_bench/review_cases/pr-2258-clean.patch b/eval/workflow_bench/review_cases/pr-2258-clean.patch new file mode 100644 index 000000000..94d8d3885 --- /dev/null +++ b/eval/workflow_bench/review_cases/pr-2258-clean.patch @@ -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 --pdg --skip-git --index-only ++ node dist/cli/index.js analyze --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 `/.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 --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 --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 .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", diff --git a/eval/workflow_bench/review_cases/pr-2258-defect.patch b/eval/workflow_bench/review_cases/pr-2258-defect.patch new file mode 100644 index 000000000..904148233 --- /dev/null +++ b/eval/workflow_bench/review_cases/pr-2258-defect.patch @@ -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 `/.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 --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 --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 .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", diff --git a/eval/workflow_bench/review_cases/pr-2718-defect.patch b/eval/workflow_bench/review_cases/pr-2718-defect.patch new file mode 100644 index 000000000..a1884d145 --- /dev/null +++ b/eval/workflow_bench/review_cases/pr-2718-defect.patch @@ -0,0 +1,1096 @@ +diff --git a/eval/workflow_bench/learnings.jsonl b/eval/workflow_bench/learnings.jsonl +index 7d25e3359..5fab5746a 100644 +--- a/eval/workflow_bench/learnings.jsonl ++++ b/eval/workflow_bench/learnings.jsonl +@@ -1,2 +1,6 @@ + {"skill": "gitnexus-work", "date": "2026-07-25", "task": "#2687 const-arrow Const/Function twin fix in parse-worker + MCP impact envelope", "friction": "Phase 2's Build-current/index-current procedure indexes the repo-under-test, which makes CLI-spawning suites (skip-git-cli, cli/tool-no-index-stderr) time out because repo resolution then opens the 237k-node index from that cwd; they pass at the same commit in an unindexed worktree, so the procedure manufactures false regressions in its own final verification.", "suggestion": "Phase 4 should note that CLI-spawn suites can fail solely because the worktree became an indexed repo, and prescribe the A/B check (same commit, unindexed worktree) instead of leaving the executor to conclude a regression."} + {"skill": "gitnexus-work", "date": "2026-07-25", "task": "#2687 same run", "friction": "Phase 2 requires top-level `status: up-to-date` before graph queries, but any uncommitted staged edit makes status report `stale` by design, so the gate is unsatisfiable in the stage -> detect_changes -> commit sequence Phase 3 mandates.", "suggestion": "Scope the up-to-date requirement to index.commit == HEAD + empty incompleteReasons + runnerIdentityStatus current, and state that a `stale` top-level status caused solely by uncommitted working-tree edits is expected at the detect_changes gate."} ++{"skill": "gitnexus-plan", "date": "2026-07-28", "task": "#2699 part B — closure binding as a call SOURCE across PHP/Rust/Kotlin/Ruby/Dart", "friction": "The safe plan writer fails closed on a v9fs (9p) worktree because renameat2(RENAME_NOREPLACE) is unsupported, returning EINVAL, so no plan can ever be published there and Phase 2's 'commit the plan document' step is unreachable.", "suggestion": "Detect the EINVAL-on-renameat2 case explicitly and fall back to open(O_EXCL)+write+fsync, which preserves the no-clobber guarantee the flag exists for; failing that, say v9fs is unsupported instead of surfacing a generic write failure."} ++{"skill": "gitnexus-work", "date": "2026-07-28", "task": "#2699 part B same run", "friction": "Every language query lives in a TypeScript template literal, so a backtick inside a `;;` comment silently terminates it and produces confusing TS1005/TS1128 parse errors far from the real edit. Hit this three separate times in one session.", "suggestion": "Phase 3 should warn that *.query.ts bodies are template literals and backticks in comments are a syntax error, or the repo should add a lint rule; the build catches it but the error location does not point at the comment."} ++{"skill": "gitnexus-work", "date": "2026-07-28", "task": "#2699 part B same run", "friction": "A module-level `const` derived from another const declared LOWER in the same file passes tsc and builds a clean dist, then throws ReferenceError (temporal dead zone) at import. It presents as N test FILES failing with ZERO failing assertions, which reads like host/infra flake rather than a code defect.", "suggestion": "Phase 3's verification note should call out that file-level failures with zero test failures usually mean a module-load error, and to grep the run output for ReferenceError before blaming the host."} ++{"skill": "gitnexus-work", "date": "2026-07-28", "task": "#2699 part B same run", "friction": "Two concurrent `vitest run` invocations on this host starve worker-pool startup: every test in both runs fails at ~5001ms against the default GITNEXUS_WORKER_READY_TIMEOUT_MS, which looks exactly like a real regression across the whole suite.", "suggestion": "Phase 3 should state that verification runs must be serial, and that a whole-suite failure at ~5001ms is worker-startup starvation, not signal."} +diff --git a/gitnexus-shared/src/scope-resolution/registries/lookup-core.ts b/gitnexus-shared/src/scope-resolution/registries/lookup-core.ts +index b98423ca6..4c0e360a8 100644 +--- a/gitnexus-shared/src/scope-resolution/registries/lookup-core.ts ++++ b/gitnexus-shared/src/scope-resolution/registries/lookup-core.ts +@@ -343,7 +343,9 @@ function resolveReceiverOwner( + * That twin also lists `Me`, deliberately NOT mirrored here: no entry in + * `SupportedLanguages` uses it, so it can only ever exempt a variable that + * happens to be called `Me`. The two lists are otherwise the same set, and +- * nothing enforces that — see the drift guard noted in #2714. ++ * that equality — plus the `Me` exemption in both directions — is now ENFORCED ++ * by `gitnexus/test/unit/receiver-twin-list-drift.test.ts`. Editing either list ++ * without the other fails there. + */ + const IMPLICIT_RECEIVERS: readonly string[] = Object.freeze(['self', 'this', '$this']); + +diff --git a/gitnexus/src/core/ingestion/languages/dart/captures.ts b/gitnexus/src/core/ingestion/languages/dart/captures.ts +index 0c6fc9e0c..6587a7302 100644 +--- a/gitnexus/src/core/ingestion/languages/dart/captures.ts ++++ b/gitnexus/src/core/ingestion/languages/dart/captures.ts +@@ -249,6 +249,15 @@ function dartCallableCallee(selector: SyntaxNode): SyntaxNode | null { + * nodes are unaffected. + */ + function findFunctionBody(declNode: SyntaxNode): SyntaxNode | null { ++ // A closure literal carries its body as a CHILD (function_expression_body), ++ // unlike a Dart declaration whose body is the next named SIBLING. Without ++ // this branch the caller synthesizes no @scope.function for a closure at all, ++ // so a closure binding has no scope to own its callable def and can never be ++ // a call SOURCE (#2699 S4 — this is why Dart alone showed zero child scopes). ++ if (declNode.type === 'function_expression') { ++ const body = declNode.namedChildren.find((c) => c.type === 'function_expression_body'); ++ return body ?? null; ++ } + const node = + declNode.parent !== null && declNode.parent.type === 'method_signature' + ? declNode.parent +diff --git a/gitnexus/src/core/ingestion/languages/dart/query.ts b/gitnexus/src/core/ingestion/languages/dart/query.ts +index 06cb3496f..954ff71e8 100644 +--- a/gitnexus/src/core/ingestion/languages/dart/query.ts ++++ b/gitnexus/src/core/ingestion/languages/dart/query.ts +@@ -92,6 +92,24 @@ const DART_SCOPE_QUERY = ` + (function_signature + name: (identifier) @declaration.name) @declaration.function) + ++; ── Declarations — closure bound to a local ────────────────────────────────── ++; ++; var handler = (int x) => target(x); / var blk = (int y) { ... }; ++; ++; Anchor discipline (same contract as javascript/query.ts): @declaration.function ++; sits on the INNER function_expression, NOT on the local_variable_declaration ++; wrapper. Dart is the one language that declares NO @scope.function in this ++; file — its function scopes are SYNTHESIZED in captures.ts from ++; declNode + findFunctionBody(declNode). So this rule deliberately does not add ++; a @scope.function of its own: doing that would collide with the synthesized ++; one at identical range, and duplicate scope ids make buildScopeTree throw, ++; which drops the whole file. Instead findFunctionBody now understands a ++; closure's child function_expression_body, so the existing synthesis produces ++; exactly one scope, anchored on the same node as the declaration (#2699 S4). ++(initialized_variable_definition ++ (identifier) @declaration.name ++ (function_expression) @declaration.function) ++ + ; ── Declarations — methods (inside class/mixin/extension bodies) ───────────── + (method_signature + (function_signature +diff --git a/gitnexus/src/core/ingestion/languages/kotlin/query.ts b/gitnexus/src/core/ingestion/languages/kotlin/query.ts +index c9d532cc9..7ec7cecc8 100644 +--- a/gitnexus/src/core/ingestion/languages/kotlin/query.ts ++++ b/gitnexus/src/core/ingestion/languages/kotlin/query.ts +@@ -115,6 +115,17 @@ const KOTLIN_SCOPE_QUERY = ` + (function_declaration + (simple_identifier) @declaration.name) @declaration.function + ++;; Lambda bound to a val/var: val handler = { x: Int -> target(x) } ++;; Anchor discipline (same contract as javascript/query.ts): @declaration.function ++;; sits on the INNER lambda_literal, NOT on the property_declaration wrapper, so ++;; anchor.range aligns with the (lambda_literal) @scope.block range. That ++;; alignment is what lets pickCallerCallableDef accept a Block-kind scope as a ++;; callable boundary: the scope IS the callable's body. The lambda stays ++;; @scope.block deliberately (#1757 smart casts) — do NOT re-kind it. ++(property_declaration ++ (variable_declaration (simple_identifier) @declaration.name) ++ (lambda_literal) @declaration.function) ++ + (property_declaration + (variable_declaration + (simple_identifier) @declaration.name)) @declaration.property +diff --git a/gitnexus/src/core/ingestion/languages/php/query.ts b/gitnexus/src/core/ingestion/languages/php/query.ts +index e24919e02..47bc65a26 100644 +--- a/gitnexus/src/core/ingestion/languages/php/query.ts ++++ b/gitnexus/src/core/ingestion/languages/php/query.ts +@@ -87,6 +87,23 @@ const PHP_SCOPE_QUERY = ` + (function_definition + name: (name) @declaration.name) @declaration.function + ++;; Closure assigned to a variable: $handler = function () {...}; or fn() => ...; ++;; Anchor discipline (same contract as javascript/query.ts): @declaration.function ++;; sits on the INNER anonymous_function / arrow_function, NOT on the ++;; assignment_expression wrapper. That aligns anchor.range with the ++;; @scope.function range above, so pass2AttachDeclarations attaches the ++;; declaration to the CLOSURE's own scope rather than the enclosing function's. ++;; Without this the closure scope owns no callable def and ++;; pickCallerCallableDef falls through to the enclosing callable, making the ++;; closure a call TARGET but never a call SOURCE (#2699). ++(assignment_expression ++ left: (variable_name) @declaration.name ++ right: (anonymous_function) @declaration.function) ++ ++(assignment_expression ++ left: (variable_name) @declaration.name ++ right: (arrow_function) @declaration.function) ++ + ;; ── Declarations — properties ───────────────────────────────────────────── + + ;; PHP 7.4+ typed property: private UserRepo $repo; +diff --git a/gitnexus/src/core/ingestion/languages/ruby/query.ts b/gitnexus/src/core/ingestion/languages/ruby/query.ts +index 853361f2d..2e09941c4 100644 +--- a/gitnexus/src/core/ingestion/languages/ruby/query.ts ++++ b/gitnexus/src/core/ingestion/languages/ruby/query.ts +@@ -79,6 +79,40 @@ const RUBY_SCOPE_QUERY = ` + (singleton_method + name: (identifier) @declaration.name) @declaration.function + ++;; ── Declarations — closure bound to a local ────────────────────────────── ++;; ++;; handler = ->(x) { target(x) } / lambda { |x| ... } / proc { |x| ... } ++;; ++;; Anchor discipline (same contract as javascript/query.ts): @declaration.function ++;; sits on the INNER (block), NOT on the assignment wrapper and NOT on the ++;; (lambda) node — the block is what carries @scope.block above, so anchoring ++;; there aligns anchor.range with the scope range. That alignment is what lets ++;; pickCallerCallableDef accept a Block-kind scope as a callable boundary. ++;; do_block/block stay @scope.block deliberately — do NOT re-kind them. ++;; ++;; The call forms are restricted to lambda/proc by name. An unrestricted ++;; (call block: (block)) would match ANY method call with a block, so ++;; mapped = items.map { |i| ... } would wrongly declare mapped a callable. ++;; Separate #eq? patterns rather than one #match? alternation: alternation ++;; predicates are a known hazard on this tree-sitter line. ++(assignment ++ left: (identifier) @declaration.name ++ right: (lambda body: (block) @declaration.function)) ++ ++(assignment ++ left: (identifier) @declaration.name ++ right: (call ++ method: (identifier) @_lambda-kw ++ block: (block) @declaration.function) ++ (#eq? @_lambda-kw "lambda")) ++ ++(assignment ++ left: (identifier) @declaration.name ++ right: (call ++ method: (identifier) @_proc-kw ++ block: (block) @declaration.function) ++ (#eq? @_proc-kw "proc")) ++ + ;; ── Declarations — variable assignment ─────────────────────────────────── + + (assignment +diff --git a/gitnexus/src/core/ingestion/languages/rust/query.ts b/gitnexus/src/core/ingestion/languages/rust/query.ts +index bef3f1bd7..2e92a0ca1 100644 +--- a/gitnexus/src/core/ingestion/languages/rust/query.ts ++++ b/gitnexus/src/core/ingestion/languages/rust/query.ts +@@ -64,6 +64,19 @@ const RUST_SCOPE_QUERY = ` + (function_signature_item + name: (identifier) @declaration.name) @declaration.function + ++;; Declarations — closure bound to a let: let handler = || target(1); ++;; Anchor discipline (same contract as javascript/query.ts): @declaration.function ++;; sits on the INNER closure_expression, NOT on the let_declaration wrapper, so ++;; anchor.range aligns with the (closure_expression) @scope.function range above. ++;; pass2AttachDeclarations then attaches the declaration to the CLOSURE's own ++;; scope instead of the enclosing block, which is what lets pickCallerCallableDef ++;; treat the closure as a call SOURCE rather than falling through to the ++;; enclosing fn (#2699). Also covers move closures — the closure_expression ++;; node spans the move keyword. ++(let_declaration ++ pattern: (identifier) @declaration.name ++ value: (closure_expression) @declaration.function) ++ + ;; Declarations — struct fields + (field_declaration + name: (field_identifier) @declaration.name +diff --git a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts +index 40632966f..caca1bee3 100644 +--- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts ++++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts +@@ -28,7 +28,10 @@ import { + simpleKey, + type GraphNodeLookup, + } from '../graph-bridge/node-lookup.js'; +-import { isOverloadableCallable } from '../../utils/callable-labels.js'; ++import { ++ isOverloadableCallable, ++ isPositionQualifiedLocalLabel, ++} from '../../utils/callable-labels.js'; + import { templateConstraintsIdTag } from '../../utils/template-arguments.js'; + import { parameterShapeIdTag } from '../../utils/method-props.js'; + /** +@@ -73,6 +76,29 @@ function rangeContainsPoint( + return true; + } + ++const isCallableDef = (d: SymbolDefinition): boolean => ++ d.type === 'Function' || d.type === 'Method' || d.type === 'Constructor'; ++ ++/** ++ * True when `range` is the body of `def` itself — the scope's start position ++ * equals the def's declaration position. ++ * ++ * Safe to compare directly: `scope-extractor.ts` builds a def id as ++ * `def:#:::` from the same `Range` ++ * a scope carries, so both sides share one coordinate base and need no ++ * conversion. (Do not "fix" this against the 1-based reading in ++ * `defStartLine`'s docblock — what matters here is that the two sides agree ++ * with each other, not which base they use.) ++ */ ++function scopeIsCallableBody( ++ range: { startLine: number; startCol: number }, ++ def: SymbolDefinition, ++): boolean { ++ const m = def.nodeId.match(/#(\d+):(\d+):/); ++ if (m === null) return false; ++ return Number(m[1]) === range.startLine && Number(m[2]) === range.startCol; ++} ++ + /** Pick the callable that owns `atRange` when multiple overloads share a class scope. */ + function pickCallerCallableDef( + scope: { +@@ -86,17 +112,30 @@ function pickCallerCallableDef( + if (atRange !== undefined) { + for (const childId of scopes.scopeTree.getChildren(scope.id)) { + const child = scopes.scopeTree.getScope(childId); +- if (child === undefined || child.kind !== 'Function') continue; ++ if (child === undefined) continue; + if (!rangeContainsPoint(child.range, atRange)) continue; +- const childCallable = child.ownedDefs.find( +- (d) => d.type === 'Function' || d.type === 'Method' || d.type === 'Constructor', +- ); +- if (childCallable !== undefined) return childCallable; ++ const childCallable = child.ownedDefs.find(isCallableDef); ++ if (childCallable === undefined) continue; ++ if (child.kind === 'Function') return childCallable; ++ // A Block-kind scope is a callable boundary ONLY when the scope IS that ++ // callable's own body. Kotlin `lambda_literal` and Ruby `do_block`/`block` ++ // are @scope.block deliberately (#1757 smart casts), so the kind gate ++ // alone would never let a closure there become a call SOURCE (#2699). ++ // ++ // But relaxing the gate to accept ANY Block owning a callable is wrong: ++ // a nested `fun foo()` declared inside a block is owned by that block, so ++ // a call made at BLOCK level — outside foo — would be misattributed to ++ // foo. The alignment test discriminates them. For a closure the ++ // declaration and the scope sit on the SAME node (the anchor discipline ++ // documented in javascript/query.ts), so their start positions match; for ++ // a nested function the block starts at `{` and the def starts at the ++ // declaration, so they do not. ++ if (child.kind === 'Block' && scopeIsCallableBody(child.range, childCallable)) { ++ return childCallable; ++ } + } + } +- return scope.ownedDefs.find( +- (d) => d.type === 'Function' || d.type === 'Method' || d.type === 'Constructor', +- ); ++ return scope.ownedDefs.find(isCallableDef); + } + + /** +@@ -170,7 +209,7 @@ export function resolveDefGraphId( + // 0-based, def ids 1-based. An `AMBIGUOUS_POSITION` tombstone (two + // callables on one line) falls through to the name-based keys below. + const line = defStartLine(def.nodeId); +- if (line !== undefined && isOverloadableCallable(def.type)) { ++ if (line !== undefined && isPositionQualifiedLocalLabel(def.type)) { + const simple = simpleNameOf(qn); + const posHit = nodeLookup.get(positionKey(filePath, def.type, line - 1, simple)); + if (posHit !== undefined && posHit !== AMBIGUOUS_POSITION) return posHit; +diff --git a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts +index 5e2789a87..1c6b94410 100644 +--- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts ++++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts +@@ -20,7 +20,10 @@ + + import type { NodeLabel, ParameterTypeClass } from 'gitnexus-shared'; + import type { KnowledgeGraph } from '../../../graph/types.js'; +-import { isOverloadableCallable } from '../../utils/callable-labels.js'; ++import { ++ isOverloadableCallable, ++ isPositionQualifiedLocalLabel, ++} from '../../utils/callable-labels.js'; + import { templateConstraintsIdTag } from '../../utils/template-arguments.js'; + import { parameterShapeIdTag } from '../../utils/method-props.js'; + +@@ -135,7 +138,7 @@ export function buildGraphNodeLookup(graph: KnowledgeGraph): GraphNodeLookup { + // Position key (#2699) — see `positionKey`. Second write on a key marks it + // ambiguous rather than letting source order decide. + const startLine = (props as { startLine?: number }).startLine; +- if (startLine !== undefined && isOverloadableCallable(node.label)) { ++ if (startLine !== undefined && isPositionQualifiedLocalLabel(node.label)) { + const posK = positionKey(props.filePath, node.label, startLine, props.name); + lookup.set(posK, lookup.has(posK) ? AMBIGUOUS_POSITION : node.id); + // A local-identity node carries `@:` on its last name segment. Record +diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts +index 8eb1d8c3d..de84c23e4 100644 +--- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts ++++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts +@@ -1332,6 +1332,20 @@ export const RUST_QUERIES = ` + ; Functions & Items + (function_item name: (identifier) @name) @definition.function + (function_signature_item name: (identifier) @name) @definition.function ++ ++; Closure bound to a let: let handler = || target(1); ++; Emits the Function NODE. Without it a Rust closure binding had no graph node ++; at all, so it could be neither a call target nor a call source (#2699), which ++; made Rust the one exception to "a closure bound to a name is a Function node ++; in every language" (#2687). ++; Anchor note: this channel puts @definition.function on the OUTER ++; let_declaration, which is the OPPOSITE of the scope-resolution channel in ++; languages/rust/query.ts (inner closure_expression, to align with ++; @scope.function). Both match their own channel's convention -- compare the ++; (lexical_declaration (variable_declarator ... (arrow_function))) rule above. ++(let_declaration ++ pattern: (identifier) @name ++ value: (closure_expression)) @definition.function + (struct_item name: (type_identifier) @name) @definition.struct + ; A union is materialized as a Struct node (same rationale as the + ; scope-resolution @declaration.struct in languages/rust/query.ts: every +diff --git a/gitnexus/src/core/ingestion/utils/ast-helpers.ts b/gitnexus/src/core/ingestion/utils/ast-helpers.ts +index 157549366..fc0d6c087 100644 +--- a/gitnexus/src/core/ingestion/utils/ast-helpers.ts ++++ b/gitnexus/src/core/ingestion/utils/ast-helpers.ts +@@ -409,6 +409,56 @@ export function findAncestorBeforeBoundary( + return null; + } + ++/** ++ * Enclosing callable for grammars that split a callable into a SIGNATURE node ++ * and a SIBLING body, where the callable is therefore never an ancestor of the ++ * code inside it. ++ * ++ * Dart is the case that forced this: `int outer() { … }` parses as ++ * `function_signature` followed by `function_body` as SIBLINGS, so an ancestor ++ * walk from a closure inside the body can never reach `outer`. No membership ++ * set fixes that — the walk is looking in the wrong direction (#2699). ++ * ++ * Deliberately a FALLBACK, used only when the ancestor walk found nothing. ++ * ++ * The sibling must be a BARE SIGNATURE, and that restriction is load-bearing — ++ * "any preceding callable sibling" is WRONG and was caught regressing PHP. In ++ * `, ++ boundaryTypes: ReadonlySet, ++): SyntaxNode | null { ++ let current = node.parent; ++ while (current !== null) { ++ if (boundaryTypes.has(current.type)) return null; ++ const prev = current.previousNamedSibling; ++ if (prev !== null && signatureOnlyTypes.has(prev.type)) return prev; ++ current = current.parent; ++ } ++ return null; ++} ++ ++// SPLIT_SIGNATURE_NODE_TYPES is defined next to LOCAL_SCOPE_BODY_NODE_TYPES, ++// which it derives from — declaring it here would read it in its temporal dead ++// zone and throw at module load (tsc does NOT catch that; only running does). ++ + /** + * Determine the graph node label from a tree-sitter capture map. + * Handles language-specific reclassification via the provider's labelOverride hook +@@ -1218,6 +1268,22 @@ export const LOCAL_SCOPE_BODY_NODE_TYPES: ReadonlySet = new Set( + ]), + ); + ++/** ++ * Callable node types whose grammar splits the body off into a SIBLING node, so ++ * the callable is never an ancestor of the code inside it (Dart ++ * `function_signature` / `method_signature`). ++ * ++ * Derived, not listed, so it cannot drift from the two sets that define it: ++ * `LOCAL_SCOPE_BODY_NODE_TYPES` is `FUNCTION_NODE_TYPES` minus exactly the bare ++ * signature types, so the difference IS the split-signature set. ++ * ++ * Must stay BELOW `LOCAL_SCOPE_BODY_NODE_TYPES` — reading it earlier hits the ++ * temporal dead zone and throws at module load. ++ */ ++export const SPLIT_SIGNATURE_NODE_TYPES: ReadonlySet = new Set( ++ [...FUNCTION_NODE_TYPES].filter((t) => !LOCAL_SCOPE_BODY_NODE_TYPES.has(t)), ++); ++ + // ============================================================================ + // Generic AST traversal helpers (shared by parse-worker + php-helpers) + // ============================================================================ +diff --git a/gitnexus/src/core/ingestion/utils/callable-labels.ts b/gitnexus/src/core/ingestion/utils/callable-labels.ts +index a4b994f43..d9f051517 100644 +--- a/gitnexus/src/core/ingestion/utils/callable-labels.ts ++++ b/gitnexus/src/core/ingestion/utils/callable-labels.ts +@@ -14,3 +14,37 @@ import type { NodeLabel } from 'gitnexus-shared'; + export function isOverloadableCallable(label: NodeLabel | undefined): boolean { + return label === 'Function' || label === 'Method' || label === 'Constructor'; + } ++ ++/** ++ * Labels whose FUNCTION-LOCAL declarations carry the enclosing-callable + ++ * position identity of #2699 (`Function:x.ts:run.save@3:2`). ++ * ++ * Wider than {@link isOverloadableCallable} on purpose. #2695 restricted the ++ * rule to callables because the collision that produced wrong CALLS edges was ++ * between callables, and widening churned ids for symbols the local-symbol ++ * pruner mostly deletes. But the issue's ORIGINAL complaint was about values: ++ * a top-level `const handler` and a function-local `const handler` collapsed ++ * onto one `Const:v.ts:handler`, and no callable gate ever reaches that. The ++ * limitation is closed here rather than carried. ++ * ++ * Only LOCALS are affected either way: the prefix comes from ++ * `enclosingCallablePrefix`, which returns `undefined` when nothing encloses ++ * the declaration, so top-level and class-member ids are untouched — that is ++ * what keeps this off the symbols other files and stored references address. ++ * A class field stays unqualified even inside a function, because the prefix ++ * walk boundaries on class-likes. ++ * ++ * ONE definition, deliberately: the id-building phase and the resolution phase ++ * must agree on this set or the caller attaches to a node that does not exist ++ * and the edge is silently dropped — the failure mode #2714 fixed, invisible ++ * from outside because "zero dangling edges" is what it looks like. ++ */ ++export function isPositionQualifiedLocalLabel(label: NodeLabel | undefined): boolean { ++ return ( ++ isOverloadableCallable(label) || ++ label === 'Variable' || ++ label === 'Const' || ++ label === 'Property' || ++ label === 'Static' ++ ); ++} +diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts +index 18bdd5bc0..c8c891fb6 100644 +--- a/gitnexus/src/core/ingestion/workers/parse-worker.ts ++++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts +@@ -82,6 +82,8 @@ import { + buildDefinitionPreScan, + FUNCTION_NODE_TYPES, + findAncestorBeforeBoundary, ++ findSplitBodyCallableAncestor, ++ SPLIT_SIGNATURE_NODE_TYPES, + getDefinitionNodeFromCaptures, + findEnclosingClassInfo, + findObjectLiteralBindingInfo, +@@ -98,6 +100,7 @@ import { + LOCAL_SCOPE_BODY_NODE_TYPES, + type SyntaxNode, + } from '../utils/ast-helpers.js'; ++import { isPositionQualifiedLocalLabel } from '../utils/callable-labels.js'; + import { extractCallArgTypes, type MixedChainStep } from '../utils/call-analysis.js'; + import { buildTypeEnv } from '../type-env.js'; + import type { ConstructorBinding } from '../type-env.js'; +@@ -821,11 +824,20 @@ const enclosingCallablePrefix = ( + // + // Over-inclusion here is the SAFE direction: an extra boundary only suppresses + // the nesting prefix, which falls back to the pre-#2699 class qualification. +- const fnNode = findAncestorBeforeBoundary( +- node, +- LOCAL_SCOPE_BODY_NODE_TYPES, +- CALLABLE_PREFIX_BOUNDARY_TYPES, +- ); ++ const fnNode = ++ findAncestorBeforeBoundary(node, LOCAL_SCOPE_BODY_NODE_TYPES, CALLABLE_PREFIX_BOUNDARY_TYPES) ?? ++ // Signature/body-split grammars: the enclosing callable is a SIBLING of the ++ // body, not an ancestor, so the walk above returns null for every local ++ // inside it. Dart is the case in hand (`function_signature` + ++ // `function_body` as siblings) — without this a Dart closure gets no ++ // prefix, so two same-named closures in one file collapse onto ONE node and ++ // the graph asserts a CALLS edge that does not exist in the source (#2699). ++ // ++ // SPLIT_SIGNATURE_NODE_TYPES, NOT FUNCTION_NODE_TYPES: only a callable that ++ // cannot hold its own body can be an enclosing callable of a SIBLING. Using ++ // the wider set mis-qualified a file-level PHP `$handler = function …` as ++ // `target.$handler` by grabbing the preceding `function target() {…}`. ++ findSplitBodyCallableAncestor(node, SPLIT_SIGNATURE_NODE_TYPES, CALLABLE_PREFIX_BOUNDARY_TYPES); + if (fnNode === null) return undefined; + return callableOwnQualifiedName(fnNode, filePath, provider); + }; +@@ -2286,13 +2298,16 @@ const processFileGroup = ( + // #2699: a callable nested inside another callable is qualified by the + // enclosing callable, so a function-local closure stops colliding with a + // same-named file-level function. Restricted to CALLABLE labels: the +- // collision that produced wrong CALLS edges is between callables, and +- // widening it to every function-local Variable/Property would churn ids +- // for symbols the local-symbol pruner mostly deletes anyway. ++ // Applies to VALUES as well as callables since #2699 closed A1: a ++ // top-level `const handler` and a function-local `const handler` ++ // otherwise collapse onto one `Const:v.ts:handler`, which was the ++ // issue's original complaint and is unreachable from a callable-only ++ // gate. `isPositionQualifiedLocalLabel` is the single definition of that ++ // set, shared with resolution in `ids.ts` — the two phases disagreeing ++ // silently drops edges rather than failing (#2714). + // Same helper as the caller-attribution phase — see `enclosingCallablePrefix`. + const nestedCallablePrefix = +- (nodeLabel === 'Function' || nodeLabel === 'Method' || nodeLabel === 'Constructor') && +- definitionNode ++ isPositionQualifiedLocalLabel(nodeLabel) && definitionNode + ? enclosingCallablePrefix(definitionNode, file.path, provider) + : undefined; + +diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts +index 5f8584fd9..263b4aa24 100644 +--- a/gitnexus/src/storage/parse-cache.ts ++++ b/gitnexus/src/storage/parse-cache.ts +@@ -55,6 +55,18 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j + // the main thread (the #1983 OOM). Because the two stores share this version, + // any future change to the `ParsedFile` serialization shape MUST bump + // SCHEMA_BUMP so both invalidate in lockstep. ++// v29: closure-binding declaration rules for PHP/Rust/Kotlin/Ruby/Dart, a Rust ++// graph node for `let f = || …`, a Dart closure scope, and function-local VALUES ++// (Variable/Const/Property/Static) qualified by their enclosing callable plus ++// position (#2699 parts A1 + B). All parse-time, so a warm cache would replay ++// the old captures and the pre-qualification ids verbatim. ++// ++// This is 29 and not 28 because of the exact collision the v21 note below warns ++// about: this branch cut at 27 and bumped to 28, while #2415 bumped 27 -> 28 and ++// merged FIRST. Re-checking against origin/main at merge time — not at branch ++// time — is what caught it; leaving it at 28 would have shipped this change with ++// NO parse-cache invalidation, so every warm cache keeps serving the pre-fix ++// captures and ids. + // v28: Java/Kotlin capture side-channels persist Spring condition facts and + // annotation-source line numbers (#2415). + // v26: the enclosing-callable walk stops at class bodies and anonymous-class +@@ -103,7 +115,7 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j + // JLS 13.1 immediate-host chains (#2555). + // v18: Worker$N anonymous bodies. v17: callable-value-flow operand identity. + // v16: direct callee identity. +-const SCHEMA_BUMP = 28; ++const SCHEMA_BUMP = 29; + const GITNEXUS_PKG_VERSION = (() => { + try { + // package.json sits at gitnexus/package.json — two levels up from +diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts +index eb46bfd82..e3abb9967 100644 +--- a/gitnexus/src/storage/repo-manager.ts ++++ b/gitnexus/src/storage/repo-manager.ts +@@ -525,8 +525,16 @@ export interface RepoMeta { + * reads it also covered are kept. A v19 index holds those false CALLS/ACCESSES on + * every unchanged file and would keep serving them through the reuse gate; force a + * full re-analyze instead. ++ * v21: a closure bound to a name is a call SOURCE in every language, not only a ++ * TARGET (#2699 part B). PHP/Rust/Kotlin/Ruby/Dart closure bindings gained the ++ * declaration rule, Rust gained the graph NODE it never emitted, and Dart locals ++ * gained the enclosing-callable + position identity that made two same-named ++ * closures collapse onto one node — which had them asserting a CALLS edge ++ * present nowhere in the source. All of that changes emitted node ids AND edges ++ * on files that did not themselves change, so a v20 index topped up ++ * incrementally keeps serving the old attribution; force a full re-analyze. + */ +-export const INCREMENTAL_SCHEMA_VERSION = 20; ++export const INCREMENTAL_SCHEMA_VERSION = 21; + + export interface IndexedRepo { + repoPath: string; +diff --git a/gitnexus/test/integration/closure-binding-labels.test.ts b/gitnexus/test/integration/closure-binding-labels.test.ts +index c0cafaa6e..4592efd2b 100644 +--- a/gitnexus/test/integration/closure-binding-labels.test.ts ++++ b/gitnexus/test/integration/closure-binding-labels.test.ts +@@ -247,7 +247,13 @@ describeIfWorkerBuilt('calls to a closure binding resolve to its Function node', + 'int caller() {\n var handler = (int x) => x;\n return handler(1);\n}\n', + ); + +- expect(targets).toEqual(['Function:local.dart:handler']); ++ // Qualified by #2699: Dart's enclosing callable is a SIBLING of the body ++ // (function_signature + function_body), so the ancestor walk that builds ++ // this prefix found nothing and every Dart local stayed bare. Two ++ // same-named closures in one file therefore collapsed onto ONE node. Now ++ // carries the same enclosing-callable + position identity as every other ++ // language. ++ expect(targets).toEqual(['Function:local.dart:caller.handler@1:2']); + }); + + it('Dart: a top-level `final` closure binding resolves', async () => { +@@ -272,7 +278,13 @@ describeIfWorkerBuilt('calls to a closure binding resolve to its Function node', + 'int caller() {\n var f = (int x) => x, g = (int y) => y;\n return f(1) + g(2);\n}\n', + ); + +- expect(targets).toEqual(['Function:multi.dart:f', 'Function:multi.dart:g']); ++ // Both declarators are function-local, so both carry the enclosing-callable ++ // + position identity (#2699). The distinct columns are the point: `g` is a ++ // nested initialized_identifier on the SAME line as `f`. ++ expect(targets).toEqual([ ++ 'Function:multi.dart:caller.f@1:2', ++ 'Function:multi.dart:caller.g@1:24', ++ ]); + }); + + it('Kotlin: a class-body closure property resolves to its Method node', async () => { +@@ -517,7 +529,7 @@ describeIfWorkerBuilt('closure bindings resolve in the remaining languages (#269 + }); + }); + +-describeIfWorkerBuilt('a closure binding is a call TARGET, not yet a call SOURCE', () => { ++describeIfWorkerBuilt('a closure binding as a call SOURCE (#2699 part B)', () => { + // Known limit, pinned deliberately so it is visible rather than surprising. + // + // A call made INSIDE a closure binding is attributed to the ENCLOSING scope, +@@ -541,31 +553,100 @@ describeIfWorkerBuilt('a closure binding is a call TARGET, not yet a call SOURCE + // scope for the walk to consider. + // + // So a fix needs per-language work, not one switch: a callable-boundary +- // signal independent of scope `kind` (Kotlin/Ruby), an association from a +- // closure scope to its binding's def (PHP), and a scope that does not exist +- // yet (Dart). See #2699. ++ // signal independent of scope `kind` (Kotlin/Ruby — DONE, S2), an ++ // association from a closure scope to its binding's def (PHP — DONE, S1; ++ // Rust — DONE, S3), and a scope that does not exist yet (Dart — STILL OPEN). ++ // See #2699. ++ // ++ // Probe-measured root cause (#2699): EVERY still-failing language has an ++ // EMPTY ownedDefs on the closure's own scope, because the closure-binding ++ // declaration rule (binding name + @declaration.function on the INNER ++ // closure node) existed only in javascript/query.ts. Kotlin and Ruby need ++ // BOTH that rule AND a relaxed kind gate — their lambda_literal / do_block ++ // is @scope.block deliberately (#1757), so the rule alone leaves them ++ // rejected. Dart has no closure scope at all: dart/query.ts declares no ++ // @scope.function, and dart/captures.ts synthesizes one only from a ++ // declaration WITH a body node, which an expression-bodied closure lacks. + // + // TS/JS free bindings are the exception: their arrow has a `@scope.function` + // with a matching range, so the closure IS the anchor there. These tests exist + // to catch that asymmetry changing in EITHER direction. + +- it('Kotlin: a call inside the closure is attributed to the file, not the binding', async () => { ++ it('Kotlin: a call inside the closure IS attributed to the binding (#2699 S2)', async () => { ++ // FLIPPED by #2699 S2, which took BOTH halves: ++ // 1. kotlin/query.ts gained the closure-binding declaration rule, with ++ // @declaration.function on the INNER lambda_literal so its range ++ // aligns with the (lambda_literal) @scope.block range; ++ // 2. pickCallerCallableDef now accepts a Block-kind scope as a callable ++ // boundary when the scope IS the callable's body (def start position ++ // == scope start position). ++ // Half 1 alone changes nothing here — the lambda stays @scope.block ++ // deliberately (#1757 smart casts), so the kind gate would still reject it. + const targets = await callEdgeIdsFor( + 'A.kt', + 'fun target(x: Int): Int = x\n\nval handler = { x: Int -> target(x) }\n', + ); + +- expect(targets).toEqual(['rel:CALLS:File:A.kt->Function:A.kt:target']); ++ expect(targets).toEqual(['rel:CALLS:Function:A.kt:handler->Function:A.kt:target']); + }); + +- it('PHP: a call inside the closure is attributed to the file, not the binding', async () => { ++ it('PHP: a call inside the closure IS attributed to the binding (#2699 S1)', async () => { ++ // FLIPPED by #2699 S1. php/query.ts now carries the closure-binding ++ // declaration rule with javascript/query.ts's anchor discipline ++ // (@declaration.function on the INNER anonymous_function, so its range ++ // aligns with the (anonymous_function) @scope.function above). The closure ++ // scope therefore owns the callable def and pickCallerCallableDef stops ++ // falling through to the enclosing scope — the closure is now a call ++ // SOURCE, not only a TARGET. + const targets = await callEdgeIdsFor( + 'a.php', + 'Function:a.php:target']); ++ expect(targets).toEqual(['rel:CALLS:Function:a.php:$handler->Function:a.php:target']); ++ }); ++ ++ it('Dart: two same-named closures in one file stay DISTINCT nodes (#2699 S4)', async () => { ++ // The defect this pins is worse than a missing edge. Before #2699 S4 gave ++ // Dart locals an enclosing-callable prefix, both closures keyed to the bare ++ // `Function:collide.dart:handler`, so ONE node appeared to call BOTH ++ // `target` and `other` — a CALLS edge that exists nowhere in the source. ++ // ++ // Dart is the only grammar here that splits a callable into a signature and ++ // a SIBLING body, so its enclosing callable was unreachable by ancestor ++ // walk and every Dart local stayed unqualified. Distinct positions in the ++ // two ids are the whole property. ++ const targets = await callEdgeIdsFor( ++ 'collide.dart', ++ 'int target(int x) => x;\nint other(int x) => x;\n' + ++ 'int outer() {\n var handler = (int x) => target(x);\n return handler(1);\n}\n' + ++ 'int second() {\n var handler = (int x) => other(x);\n return handler(2);\n}\n', ++ ); ++ ++ // The trailing `:5:9` / `:9:9` on the first and third edges is the CALL ++ // SITE, not part of the node id: invoking a closure binding is an indirect ++ // call emitted by the callable-value-flow pass, which keys its edge by the ++ // invocation position. The direct `handler -> target` calls carry no such ++ // suffix. Do not "normalize" these away — they are different edge kinds. ++ expect(targets).toEqual([ ++ 'rel:CALLS:Function:collide.dart:outer->Function:collide.dart:outer.handler@3:2:5:9', ++ 'rel:CALLS:Function:collide.dart:outer.handler@3:2->Function:collide.dart:target', ++ 'rel:CALLS:Function:collide.dart:second->Function:collide.dart:second.handler@7:2:9:9', ++ 'rel:CALLS:Function:collide.dart:second.handler@7:2->Function:collide.dart:other', ++ ]); ++ }); ++ ++ it('Ruby: a call inside a lambda binding IS attributed to the binding (#2699 S2)', async () => { ++ // Ruby had no pinned case before #2699 S2, so this is new coverage rather ++ // than an inverted assertion. do_block/block stay @scope.block (matching ++ // Kotlin), so this exercises the same Block-scope alignment path. ++ const targets = await callEdgeIdsFor( ++ 'a.rb', ++ 'def target(x)\n x\nend\n\nhandler = ->(x) { target(x) }\n', ++ ); ++ ++ expect(targets).toEqual(['rel:CALLS:Function:a.rb:handler->Method:a.rb:target#1']); + }); + + it('JavaScript: a free arrow binding IS the caller anchor', async () => { +@@ -653,7 +734,11 @@ describeIfWorkerBuilt('a value binding is never aliased onto a same-named callab + 'int run() {\n var save = (int x) => x * 2;\n return save(1);\n}\n', + ); + +- expect(targets).toEqual(['Function:svc.dart:save']); ++ // The target is the LOCAL closure, never `Svc.save`. Since #2699 the local ++ // also carries its enclosing callable and position, so the two are now ++ // distinct by id and not merely by which node the edge happened to reach — ++ // `run.save@5:2` cannot collide with the method however the lookup is keyed. ++ expect(targets).toEqual(['Function:svc.dart:run.save@5:2']); + }); + + it('Kotlin: a genuine constant mints no CALLS', async () => { +diff --git a/gitnexus/test/integration/function-local-identity.test.ts b/gitnexus/test/integration/function-local-identity.test.ts +index e5c1d488d..e8050718e 100644 +--- a/gitnexus/test/integration/function-local-identity.test.ts ++++ b/gitnexus/test/integration/function-local-identity.test.ts +@@ -281,3 +281,74 @@ describeIfWorkerBuilt('a function-local callable does not collide with a file-le + ]); + }); + }); ++ ++/** Node ids for `name`, with local value symbols kept so the pruner can't hide them. */ ++const valueNodeIdsFor = async ( ++ filename: string, ++ source: string, ++ name: string, ++): Promise => { ++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-local-value-identity-')); ++ try { ++ fs.writeFileSync(path.join(dir, filename), source, 'utf-8'); ++ const result = await runPipelineFromRepo(dir, () => {}, { ++ workerPoolSize: 1, ++ workerUrlForTest: DIST_WORKER_URL, ++ // `pruneLocalSymbols` deletes ~94% of inert function-local value symbols, ++ // which would make the collapse below invisible rather than absent. ++ keepLocalValueSymbols: true, ++ }); ++ return result.graph.nodes ++ .filter((node) => node.properties.name === name) ++ .map((node) => node.id) ++ .sort(); ++ } finally { ++ fs.rmSync(dir, { recursive: true, force: true }); ++ } ++}; ++ ++describeIfWorkerBuilt('function-local VALUES carry their own identity (#2699 A1)', () => { ++ it('a function-local VALUE does not collapse onto the file-level node', async () => { ++ // FLIPPED, per this test's own former instruction. It previously pinned the ++ // collapse as a KNOWN LIMIT: #2695 gave function-local CALLABLES a ++ // position-bearing id and deliberately excluded VALUES, so a top-level ++ // `const handler` and a function-local `const handler` shared ONE node. ++ // That was the residual half of #2699's ORIGINAL complaint — the issue is ++ // about values first, and no callable-only gate could ever reach it. ++ // ++ // Widened here via `isPositionQualifiedLocalLabel`, the single definition ++ // shared by all THREE phases that must agree: id-building ++ // (`parse-worker.ts`), resolution (`ids.ts` position key) and registration ++ // (`node-lookup.ts`). Two of them disagreeing does not fail loudly — the ++ // caller attaches to a node that does not exist and the edge is silently ++ // dropped, which is the #2714 failure mode. ++ // ++ // The churn this was deferred for is real and was accepted deliberately: ++ // it re-keys ~14,700 build-time nodes to change ~800 persisted ones, ++ // because `pruneLocalSymbols` deletes most locals. Hence the paired ++ // INCREMENTAL_SCHEMA_VERSION / parse-cache SCHEMA_BUMP bumps — without them ++ // a warm cache or an incremental top-up replays the old un-suffixed ids. ++ // ++ // Only LOCALS move. The prefix comes from `enclosingCallablePrefix`, which ++ // returns undefined when nothing encloses the declaration, so the ++ // file-level `handler` below keeps its bare id — that is what keeps this ++ // off the symbols other files and stored references address. ++ const ids = await valueNodeIdsFor( ++ 'v.ts', ++ [ ++ "export const handler = 'top-level value';", ++ '', ++ 'export function run(): string {', ++ " const handler = 'function-local value';", ++ ' return handler;', ++ '}', ++ '', ++ ].join('\n'), ++ 'handler', ++ ); ++ ++ // Two distinct nodes: the file-level one keeps its bare id, the local ++ // carries its enclosing callable AND declaration position. ++ expect(ids).toEqual(['Const:v.ts:handler', 'Const:v.ts:run.handler@3:2']); ++ }); ++}); +diff --git a/gitnexus/test/integration/this-boundary.test.ts b/gitnexus/test/integration/this-boundary.test.ts +index 8aec71641..09a3e7be0 100644 +--- a/gitnexus/test/integration/this-boundary.test.ts ++++ b/gitnexus/test/integration/this-boundary.test.ts +@@ -188,10 +188,14 @@ describeIfWorkerBuilt('an arrow inherits `this`; every other function form binds + '\n', + ), + ), +- // Attributed to `run`, not to `f`: Kotlin scopes `lambda_literal` as a +- // BLOCK (#1757), so the lambda is not its own caller anchor. What matters +- // here is only that the `this.m()` edge still exists at all. +- ).toContain('Method:K.kt:K.run#0 -> Method:K.kt:K.m#0'); ++ // Attributed to `f` since #2699 S2. Kotlin still scopes `lambda_literal` ++ // as a BLOCK (#1757 — that has NOT changed), but a Block-kind scope is now ++ // accepted as a caller anchor when the scope IS the callable's body, so ++ // the lambda is its own anchor. The property this test exists for is ++ // unchanged and is what the assertion still checks: `this` inside a Kotlin ++ // lambda resolves to the enclosing receiver, so the `this.m()` edge exists. ++ // Only its SOURCE moved, from `run` to `run.f`. ++ ).toContain('Method:K.kt:K.run.f@2:16 -> Method:K.kt:K.m#0'); + }); + }); + +diff --git a/gitnexus/test/unit/call-summary-schema-version.test.ts b/gitnexus/test/unit/call-summary-schema-version.test.ts +index 6838c99eb..5141c8147 100644 +--- a/gitnexus/test/unit/call-summary-schema-version.test.ts ++++ b/gitnexus/test/unit/call-summary-schema-version.test.ts +@@ -73,8 +73,12 @@ describe('CALL_SUMMARY relation-type exclusion (U-C1)', () => { + }); + + describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => { +- it('INCREMENTAL_SCHEMA_VERSION is bumped to 20 (named-receiver lexical fallback, #2699)', () => { +- expect(INCREMENTAL_SCHEMA_VERSION).toBe(20); ++ it('INCREMENTAL_SCHEMA_VERSION is bumped to 21 (closure bindings are call SOURCES, #2699 part B)', () => { ++ // Moves with every bump BY DESIGN — that is the point of pinning it. A ++ // change that alters emitted ids or edges without bumping would otherwise ++ // ship silently, and an existing index would keep serving the old graph ++ // through the reuse gate below. ++ expect(INCREMENTAL_SCHEMA_VERSION).toBe(21); + }); + + it('a pre-current stamp fails the `=== INCREMENTAL_SCHEMA_VERSION` reuse gate → forces full re-analyze', () => { +@@ -155,7 +159,16 @@ describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => { + // `const baseUrl`) — 709 of them on a 762-file corpus. Reusing it would keep + // every one on unchanged files. + expect(passesReuseGate(19)).toBe(false); ++ // A pre-v21 (v20) index predates closure bindings becoming call SOURCES in ++ // PHP/Rust/Kotlin/Ruby/Dart, the Rust graph node for `let f = || …`, the Dart ++ // closure scope + enclosing-callable identity, and position-qualified ++ // function-local VALUES. All of those change emitted ids and edges on files ++ // that did not themselves change, so reusing a v20 index keeps serving the ++ // old attribution — including the Dart case where two same-named closures ++ // collapsed onto one node and asserted a CALLS edge present nowhere in the ++ // source. ++ expect(passesReuseGate(20)).toBe(false); + // A current-version stamp passes the gate (incremental top-up eligible). +- expect(passesReuseGate(20)).toBe(true); ++ expect(passesReuseGate(21)).toBe(true); + }); + }); +diff --git a/gitnexus/test/unit/callable-id-lockstep.test.ts b/gitnexus/test/unit/callable-id-lockstep.test.ts +index d22266418..cf7726f6b 100644 +--- a/gitnexus/test/unit/callable-id-lockstep.test.ts ++++ b/gitnexus/test/unit/callable-id-lockstep.test.ts +@@ -64,8 +64,13 @@ describe('no call site re-inlines the rule', () => { + it('parse-worker.ts contains no inlined `.${localIdentity(...)}` template', () => { + // The structural half. The unit assertions above would still pass if a + // fourth phase appeared and spelled the rule out by hand — which is +- // exactly how the divergence #2714 fixed came to exist. This fails if any +- // site reconstructs the id instead of calling the shared function. ++ // exactly how the divergence #2714 fixed came to exist. ++ // ++ // Scope, stated honestly: this matches ONE template spelling — the ++ // `${prefix}.${localIdentity(...)}` form the divergence actually took. A ++ // hand-rolled id built by string concatenation, or with the interpolation ++ // spelled differently, still slips past. It is a tripwire for the known ++ // shape, not a proof that no site reconstructs the id. + const source = readFileSync( + fileURLToPath(new URL('../../src/core/ingestion/workers/parse-worker.ts', import.meta.url)), + 'utf8', +diff --git a/gitnexus/test/unit/detect-changes-local-id-stability.test.ts b/gitnexus/test/unit/detect-changes-local-id-stability.test.ts +new file mode 100644 +index 000000000..708292bd1 +--- /dev/null ++++ b/gitnexus/test/unit/detect-changes-local-id-stability.test.ts +@@ -0,0 +1,75 @@ ++/** ++ * #2699 consumer audit — `detect_changes` must not key on node ids. ++ * ++ * #2695/#2714 gave function-local CALLABLES position-bearing ids ++ * (`Function:x.ts:run.save@3:2`). That raised a specific worry for this ++ * consumer: an id containing `@row:col` changes whenever the declaration ++ * MOVES, even when the code is byte-identical, so an id-keyed ++ * `detect_changes` would report churn for every edit above a local. ++ * ++ * The worry is unfounded, and this file pins why. `detect_changes` maps diff ++ * hunks to symbols by LINE-RANGE OVERLAP — it matches `n.startLine`/`n.endLine` ++ * against the hunk bounds and merely REPORTS `n.id`. Node identity never ++ * participates in the match, so a position-bearing id cannot inflate ++ * `changed_count`. ++ * ++ * These are structural (source-grep) assertions, in the same idiom as ++ * `detect-changes-worktree.test.ts`: they prove the query still has the shape ++ * the audit verified, and would fail loudly if someone switched the mapping to ++ * id equality. They do NOT execute the query — the behavioural coverage for ++ * detect_changes lives in the MCP integration suites. ++ */ ++import { describe, expect, it } from 'vitest'; ++import { readFileSync } from 'fs'; ++import path from 'path'; ++import { fileURLToPath } from 'url'; ++ ++const __dirname = path.dirname(fileURLToPath(import.meta.url)); ++const backendSrc = readFileSync( ++ path.join(__dirname, '../../src/mcp/local/local-backend.ts'), ++ 'utf-8', ++); ++ ++/** The hunk→symbol query, isolated so the assertions below can't match text elsewhere. */ ++const symbolQuery = (): string => { ++ const start = backendSrc.indexOf('const symbolQuery = `'); ++ expect(start, 'symbolQuery template not found — update this test').toBeGreaterThan(-1); ++ const from = backendSrc.indexOf('`', start) + 1; ++ const to = backendSrc.indexOf('`', from); ++ return backendSrc.slice(from, to); ++}; ++ ++describe('#2699 audit — detect_changes maps hunks to symbols by position, not id', () => { ++ it('matches on startLine/endLine, so a moved local cannot register as churn', () => { ++ const q = symbolQuery(); ++ ++ expect(q).toContain('n.startLine IS NOT NULL'); ++ expect(q).toContain('n.endLine IS NOT NULL'); ++ }); ++ ++ it('never matches a symbol by node id', () => { ++ // The guard that matters. `n.id` may be SELECTED (it is reported back to ++ // the caller) but must not appear in a WHERE-side equality against a ++ // parameter — that would reintroduce the id-churn failure mode. ++ const q = symbolQuery(); ++ const whereClause = q.slice(q.indexOf('WHERE'), q.indexOf('RETURN')); ++ ++ expect(whereClause).not.toMatch(/n\.id\s*=/); ++ expect(whereClause).not.toMatch(/n\.id\s+IN\b/); ++ }); ++ ++ it('still excludes BasicBlock rows by id prefix (#2082 U7)', () => { ++ // The one legitimate id-shaped predicate: a PREFIX filter that drops ++ // nameless PDG substrate. Pinned so the assertion above cannot be ++ // satisfied by deleting this exclusion. ++ const q = symbolQuery(); ++ ++ expect(q).toContain("NOT n.id STARTS WITH 'BasicBlock:'"); ++ }); ++ ++ it('reports the id rather than matching on it', () => { ++ const q = symbolQuery(); ++ ++ expect(q.slice(q.indexOf('RETURN'))).toContain('n.id AS id'); ++ }); ++}); +diff --git a/gitnexus/test/unit/receiver-twin-list-drift.test.ts b/gitnexus/test/unit/receiver-twin-list-drift.test.ts +new file mode 100644 +index 000000000..24e23987a +--- /dev/null ++++ b/gitnexus/test/unit/receiver-twin-list-drift.test.ts +@@ -0,0 +1,99 @@ ++/** ++ * The drift guard for the implicit-receiver twin lists (#2699 follow-up). ++ * ++ * TWO lists spell "this is an implicit receiver", in two packages: ++ * ++ * - `IMPLICIT_RECEIVERS` — gitnexus-shared `lookup-core.ts`. Two consumers: ++ * the Step-1 lexical skip (a NAMED receiver must not resolve its member ++ * through the lexical chain) and `resolveReceiverOwner`. ++ * - `THIS_RECEIVERS` — gitnexus `type-env.ts`. Decides whether a receiver ++ * rewrites to the enclosing type. ++ * ++ * They are the SIXTH twin-list instance found in this family of work, and the ++ * previous five each shipped a bug when one side moved. `$this` was added to ++ * the shared list in #2714 precisely because it was already in the other one; ++ * nothing but this test stops the next divergence. ++ * ++ * `Me` is the one deliberate asymmetry: `THIS_RECEIVERS` carries it (Visual ++ * Basic spelling) and the shared list does not, because no entry in ++ * `SupportedLanguages` uses it — mirroring it there could only ever exempt a ++ * variable that happens to be named `Me`. That exemption is asserted ++ * explicitly rather than tolerated, so RE-adding `Me` to the shared list, or ++ * dropping it from the local one, both fail loudly. ++ * ++ * Structural (source-parsed) rather than value-imported: both constants are ++ * module-private, and exporting them purely to be testable would widen two ++ * public surfaces to satisfy a test. Same idiom as ++ * `detect-changes-local-id-stability.test.ts`. ++ */ ++import { describe, expect, it } from 'vitest'; ++import { readFileSync } from 'fs'; ++import path from 'path'; ++import { fileURLToPath } from 'url'; ++ ++const __dirname = path.dirname(fileURLToPath(import.meta.url)); ++ ++/** ++ * String literals inside the first `[...]` following `marker` that actually ++ * CONTAINS a string literal. ++ * ++ * "First `[`" is not good enough: `IMPLICIT_RECEIVERS` is declared ++ * `: readonly string[] = Object.freeze([...])`, so the first bracket belongs to ++ * the TYPE annotation and yields an empty list — which would make every ++ * assertion below vacuously pass. That is exactly what the non-empty check in ++ * the first test exists to catch, and it did. ++ */ ++const literalsAfter = (source: string, marker: string): string[] => { ++ const at = source.indexOf(marker); ++ expect(at, `${marker} not found — update this test`).toBeGreaterThan(-1); ++ for (let open = source.indexOf('[', at); open !== -1; open = source.indexOf('[', open + 1)) { ++ const close = source.indexOf(']', open); ++ if (close === -1) break; ++ const names = [...source.slice(open + 1, close).matchAll(/'([^']*)'|"([^"]*)"/g)] ++ .map((m) => m[1] ?? m[2] ?? '') ++ .filter((s) => s.length > 0); ++ if (names.length > 0) return names.sort(); ++ } ++ return []; ++}; ++ ++const sharedList = (): string[] => ++ literalsAfter( ++ readFileSync( ++ path.join( ++ __dirname, ++ '../../../gitnexus-shared/src/scope-resolution/registries/lookup-core.ts', ++ ), ++ 'utf-8', ++ ), ++ 'const IMPLICIT_RECEIVERS', ++ ); ++ ++const typeEnvList = (): string[] => ++ literalsAfter( ++ readFileSync(path.join(__dirname, '../../src/core/ingestion/type-env.ts'), 'utf-8'), ++ 'const THIS_RECEIVERS', ++ ); ++ ++describe('#2699 — implicit-receiver twin lists do not drift', () => { ++ it('both lists are non-empty and were actually parsed', () => { ++ // Guards the guard: a regex that silently matched nothing would make every ++ // assertion below vacuously true. ++ expect(sharedList().length).toBeGreaterThan(0); ++ expect(typeEnvList().length).toBeGreaterThan(0); ++ }); ++ ++ it('the shared list is exactly the type-env list minus the deliberate `Me`', () => { ++ expect(sharedList()).toEqual(typeEnvList().filter((name) => name !== 'Me')); ++ }); ++ ++ it('`Me` stays OUT of the shared list', () => { ++ // Stated separately so the intent survives even if the set comparison above ++ // is ever relaxed: this asymmetry is a decision, not an oversight. ++ expect(sharedList()).not.toContain('Me'); ++ }); ++ ++ it('`Me` stays IN the type-env list', () => { ++ expect(typeEnvList()).toContain('Me'); ++ }); ++}); diff --git a/eval/workflow_bench/review_cases/pr-2773-clean.patch b/eval/workflow_bench/review_cases/pr-2773-clean.patch new file mode 100644 index 000000000..85ee46528 --- /dev/null +++ b/eval/workflow_bench/review_cases/pr-2773-clean.patch @@ -0,0 +1,1725 @@ +diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts +index 86f763ffa..d107f5e79 100644 +--- a/gitnexus/src/core/lbug/lbug-adapter.ts ++++ b/gitnexus/src/core/lbug/lbug-adapter.ts +@@ -3077,6 +3077,55 @@ export const ensureFTSIndex = async ( + } + }; + ++export type FtsQueryFailureClass = 'missing-index' | 'missing-table' | 'other'; ++ ++/** ++ * Classify a `QUERY_FTS_INDEX` failure so a genuinely-missing index (normal — ++ * this table's FTS index hasn't been built yet) is distinguished from a real ++ * query-time error that would otherwise look identical (#2767), and from the ++ * table itself being missing (schema drift / a corrupted or partial DB — a ++ * much more serious condition than an unbuilt index). ++ * ++ * tri-review Residual-1: this used to be a second, independently-maintained ++ * classifier living in `core/search/bm25-index.ts` (re-exported from there ++ * for backward compatibility), duplicating this function's job for the ++ * IDENTICAL `QUERY_FTS_INDEX` cypher call. `queryFTS` below now uses this ++ * same classifier for its own catch instead of a bare, unanchored ++ * `.includes('does not exist')` check that could not tell "index missing" ++ * from "table missing" apart, and silently swallowed both alike. ++ * ++ * Three real message shapes were confirmed empirically against a live ++ * `CALL QUERY_FTS_INDEX(...)`: ++ * `"Prepare failed: Binder exception: Table doesn't have an index with ++ * name ."` — the table exists, only its FTS index is missing (normal, ++ * benign — `missing-index`) — `"Prepare failed: Binder exception: Table ++ * does not exist."` — the TABLE ITSELF is missing (`missing-table`) — and a ++ * `Catalog exception: function QUERY_FTS_INDEX is not defined...` when the ++ * FTS extension isn't loaded at all (`other`; mirrors the confirmed ++ * `DROP_FTS_INDEX` shape in {@link isBenignDropFtsIndexError}'s doc comment). ++ * ++ * Anchored to the exception class (after stripping the optional "Prepare ++ * failed: " wrapper LadybugDB adds for statement-preparation failures), ++ * mirroring `isBenignDropFtsIndexError`'s START-of-message anchor: a bare ++ * substring search would misclassify a genuine, differently-classed error ++ * (e.g. a `Runtime exception` from the FTS parser that echoes the user's ++ * own search text back into its message) as benign whenever that echoed ++ * text happened to contain "does not exist" — silently dropping a real ++ * error, the exact #2767 failure mode this function exists to prevent. ++ */ ++export const classifyFtsQueryError = (message: string): FtsQueryFailureClass => { ++ const PREPARE_FAILED_PREFIX = 'Prepare failed: '; ++ const body = message.startsWith(PREPARE_FAILED_PREFIX) ++ ? message.slice(PREPARE_FAILED_PREFIX.length) ++ : message; ++ if (!body.startsWith('Binder exception:') && !body.startsWith('Catalog exception:')) { ++ return 'other'; ++ } ++ if (body.includes("doesn't have an index")) return 'missing-index'; ++ if (body.includes('does not exist')) return 'missing-table'; ++ return 'other'; ++}; ++ + /** + * Query a full-text search index + * @param tableName - The node table name +@@ -3121,8 +3170,13 @@ export const queryFTS = async ( + }; + }); + } catch (e: any) { +- // Return empty if index doesn't exist yet +- if (e.message?.includes('does not exist')) { ++ // Return empty only for a genuinely-missing index — the ordinary, ++ // expected case. A missing TABLE (schema drift) or any other real error ++ // rethrows instead of being silently swallowed (tri-review Residual-1 / ++ // NEW-6 — this used to be a bare `.includes('does not exist')` check ++ // that could not tell the two apart). ++ const message = e instanceof Error ? e.message : String(e); ++ if (classifyFtsQueryError(message) === 'missing-index') { + return []; + } + throw e; +diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts +index 0908175d8..754c1e6b7 100644 +--- a/gitnexus/src/core/run-analyze.ts ++++ b/gitnexus/src/core/run-analyze.ts +@@ -1031,6 +1031,47 @@ async function runFullAnalysisInner( + ); + } + await ensureGitNexusIgnored(repoPath); ++ // #2767: stamp ONLY capabilities.fts so a long-lived MCP session's ++ // ensureInitialized() has an explicit, correctly-scoped signal that FTS ++ // changed — indexedAt/lastCommit/runnerIdentity/stats are copied through ++ // untouched (see the "must not claim a new analyzer identity" comment ++ // below). capabilities is forensic/no-programmatic-readers-until-now, so ++ // graph/vectorSearch are backfilled with conservative, honest defaults ++ // when a legacy meta.json predates this field entirely — repair-fts ++ // never touched them and cannot claim a capability it did not verify. ++ // Best-effort: a write failure must not turn an already-successful FTS ++ // rebuild into a reported repair failure. ++ try { ++ // Re-read the on-disk meta immediately before writing, rather than ++ // reusing `existingMeta` (captured before the FTS rebuild ran, which ++ // can span real wall-clock time). Another writer to this same ++ // gitnexus.json in the interim — e.g. the HTTP server's background ++ // embedding-checkpoint job — must not have its update silently ++ // reverted by this stamp overwriting a stale snapshot. Falls back to ++ // `existingMeta` only if the file became unreadable in that window. ++ const latestMeta = (await loadMeta(metaDir)) ?? existingMeta; ++ await saveMeta(metaDir, { ++ ...latestMeta, ++ capabilities: { ++ graph: latestMeta.capabilities?.graph ?? { ++ provider: 'ladybugdb', ++ status: 'available', ++ }, ++ fts: { provider: 'ladybugdb-fts', status: 'available' }, ++ vectorSearch: latestMeta.capabilities?.vectorSearch ?? { ++ provider: 'exact-scan', ++ status: 'unavailable', ++ exactScanLimit: 0, ++ }, ++ }, ++ }); ++ } catch (err) { ++ log( ++ `FTS capability stamp write failed (non-critical, repair itself succeeded${ ++ err instanceof Error ? `: ${err.message}` : '' ++ }); continuing.`, ++ ); ++ } + progress('fts', 90, 'Search indexes ready'); + progress('done', 100, 'Done'); + return { +diff --git a/gitnexus/src/core/search/bm25-index.ts b/gitnexus/src/core/search/bm25-index.ts +index 19196b11b..44cfe4ce2 100644 +--- a/gitnexus/src/core/search/bm25-index.ts ++++ b/gitnexus/src/core/search/bm25-index.ts +@@ -5,8 +5,14 @@ + * Always reads from the database (no cached state to drift). + */ + +-import { queryFTS } from '../lbug/lbug-adapter.js'; ++// tri-review Residual-1: `classifyFtsQueryError` now lives in lbug-adapter.ts ++// (see its doc comment) so `queryFTS`'s own catch can share the SAME ++// classifier instead of maintaining a second, independently-drifting copy ++// for the identical `QUERY_FTS_INDEX` cypher call. ++import { queryFTS, classifyFtsQueryError } from '../lbug/lbug-adapter.js'; + import { normalizeFtsText } from '../lbug/csv-generator.js'; ++import { getExtensionCapabilities } from '../lbug/extension-loader.js'; ++import { redactPaths } from './fts-indexes.js'; + import { FTS_INDEXES } from './fts-schema.js'; + import { + applyCjkSegmentationIfEnabled, +@@ -24,12 +30,40 @@ export interface FTSSearchResponse { + results: BM25SearchResult[]; + /** True when at least one FTS index query succeeded (index exists). */ + ftsAvailable: boolean; ++ /** ++ * Redacted (via {@link redactPaths}) message(s) from per-table ++ * `QUERY_FTS_INDEX` calls that failed for a reason OTHER than "index ++ * doesn't exist" (#2767) — a real query/connection error was previously ++ * indistinguishable from a genuinely-missing index. Populated whenever ANY ++ * table hit a non-benign error, regardless of whether other tables ++ * succeeded, so a caller can always log it; whether to also surface it in ++ * a client-facing warning is a caller decision (see `LocalBackend.query()`, ++ * which only does so when every table failed). ++ */ ++ nonBenignErrors?: string[]; ++} ++ ++/** ++ * Optional-field shape rather than a discriminated union: this project builds ++ * with `strict: false` (no `strictNullChecks`), under which TypeScript's ++ * control-flow narrowing across an `if/else` on a boolean discriminant is ++ * unreliable (verified empirically — narrows correctly under `strict: true`, ++ * fails under `strict: false`). `rows` present means success; `rows` absent ++ * means failure, with `benign`/`message` describing why. ++ */ ++interface FTSQueryOutcome { ++ rows?: Array<{ filePath: string; score: number; nodeId: string }>; ++ benign?: boolean; ++ message?: string; + } + + /** + * Execute a single FTS query via a custom executor (for MCP connection pool). +- * Returns `null` when the query fails (e.g. FTS index does not exist) so the +- * caller can distinguish "zero matches" from "index missing". ++ * Returns a benign failure when the query fails because the index doesn't ++ * exist (the normal, expected case), and a non-benign failure with the ++ * captured message for any other error, so the caller can distinguish "zero ++ * matches", "index missing", and "a real error occurred" instead of ++ * collapsing the latter two into the same silent `null`. + */ + async function queryFTSViaExecutor( + executor: (cypher: string, params: Record) => Promise, +@@ -37,7 +71,7 @@ async function queryFTSViaExecutor( + indexName: string, + query: string, + limit: number, +-): Promise | null> { ++): Promise { + const cypher = ` + CALL QUERY_FTS_INDEX('${tableName}', '${indexName}', $query, conjunctive := false) + RETURN node, score +@@ -46,17 +80,20 @@ async function queryFTSViaExecutor( + `; + try { + const rows = await executor(cypher, { query }); +- return rows.map((row: any) => { +- const node = row.node || row[0] || {}; +- const score = row.score ?? row[1] ?? 0; +- return { +- filePath: node.filePath || '', +- score: typeof score === 'number' ? score : parseFloat(score) || 0, +- nodeId: node.nodeId || node.id || '', +- }; +- }); +- } catch { +- return null; ++ return { ++ rows: rows.map((row: any) => { ++ const node = row.node || row[0] || {}; ++ const score = row.score ?? row[1] ?? 0; ++ return { ++ filePath: node.filePath || '', ++ score: typeof score === 'number' ? score : parseFloat(score) || 0, ++ nodeId: node.nodeId || node.id || '', ++ }; ++ }), ++ }; ++ } catch (e) { ++ const message = e instanceof Error ? e.message : String(e); ++ return { benign: classifyFtsQueryError(message) === 'missing-index', message }; + } + } + +@@ -95,8 +132,23 @@ export const searchFTSFromLbug = async ( + ); + const resultsByIndex: any[][] = []; + let queriesSucceeded = 0; ++ const nonBenignErrors: string[] = []; + +- if (repoId) { ++ const ftsExtension = getExtensionCapabilities().find((c) => c.name === 'fts'); ++ if (ftsExtension && !ftsExtension.loaded) { ++ // tri-review NEW-4 (applies to BOTH the MCP pool path and the CLI/pipeline ++ // path — a /simplify altitude pass caught the original repoId-only guard ++ // letting the CLI branch surface spurious "non-benign" errors for this ++ // exact expected state, which the pool branch correctly stayed silent on): ++ // extension-unavailable is an expected, already-diagnosed degraded-capability ++ // state (#2374/#2658), not a per-table query error — every configured table ++ // would throw the identical "function not defined" shape, which ++ // classifyFtsQueryError correctly refuses to call benign "missing-index" ++ // (it's a different, more serious condition). Skip the N redundant ++ // QUERY_FTS_INDEX round-trips and N nonBenignErrors entries; ftsAvailable ++ // stays false and ftsDegradedWarning() already reports this state ++ // accurately from the same extension-capabilities registry. ++ } else if (repoId) { + // Use MCP connection pool via dynamic import + // IMPORTANT: FTS queries run sequentially to avoid connection contention. + // The MCP pool supports multiple connections, but FTS is best run serially. +@@ -106,21 +158,28 @@ export const searchFTSFromLbug = async ( + executeParameterized(repoId, cypher, params); + + for (const { table, indexName } of FTS_INDEXES) { +- const result = await queryFTSViaExecutor(executor, table, indexName, searchQuery, limit); +- if (result !== null) { ++ const outcome = await queryFTSViaExecutor(executor, table, indexName, searchQuery, limit); ++ if (outcome.rows) { + queriesSucceeded++; +- resultsByIndex.push(result); ++ resultsByIndex.push(outcome.rows); ++ } else if (!outcome.benign) { ++ nonBenignErrors.push(redactPaths(outcome.message ?? 'Unknown FTS query error')); + } + } + } else { + // Use core lbug adapter (CLI / pipeline context) — also sequential for safety. ++ // tri-review Residual-1: `queryFTS` itself only swallows a genuinely-missing ++ // index (via the SAME classifyFtsQueryError this module re-exports); a ++ // missing-table or real query error rethrows here — track it the same way ++ // the MCP pool path does instead of a bare `catch {}` that dropped it. + for (const { table, indexName } of FTS_INDEXES) { + try { + const result = await queryFTS(table, indexName, searchQuery, limit, false); + queriesSucceeded++; + resultsByIndex.push(result); +- } catch { +- // FTS index may not exist — count as failed ++ } catch (e) { ++ const message = e instanceof Error ? e.message : String(e); ++ nonBenignErrors.push(redactPaths(message)); + } + } + } +@@ -165,5 +224,6 @@ export const searchFTSFromLbug = async ( + nodeIds: r.nodeIds, + })), + ftsAvailable, ++ ...(nonBenignErrors.length > 0 && { nonBenignErrors }), + }; + }; +diff --git a/gitnexus/src/core/search/fts-indexes.ts b/gitnexus/src/core/search/fts-indexes.ts +index ab098a68c..dfc55286c 100644 +--- a/gitnexus/src/core/search/fts-indexes.ts ++++ b/gitnexus/src/core/search/fts-indexes.ts +@@ -11,17 +11,62 @@ import { FTS_INDEXES } from './fts-schema.js'; + * ELF header", "has not been installed") have no leading path separator and + * survive. CLI/doctor/log surfaces keep the full path (they read the reason + * directly, not through this function). ++ * ++ * tri-review Residual-3: every real message shape observed from LadybugDB ++ * wraps the path in single quotes (`Failed to load library '': ...`), ++ * so a QUOTED path is redacted first, consuming through its closing quote — ++ * spaces included (e.g. a Windows username like `alice smith`). The original ++ * unquoted-stop-at-first-whitespace pattern still runs afterward as a ++ * fallback for the rare case of a path appearing without quotes; that path's ++ * own known limitation (partial redaction if it itself contains a space) is ++ * unchanged, but is no longer the ONLY path this function knows how to redact. ++ */ ++export const redactPaths = (reason: string): string => ++ reason ++ .replace(/'((?:[A-Za-z]:\\|\/)[^']*)'/g, "''") ++ .replace(/(?:[A-Za-z]:\\|\/)[^\s'"]+/g, ''); ++ ++/** ++ * Resolved-repo/index identity a caller can attach to a degraded-FTS warning ++ * (#2767) so a reader can tell whether *this* session even resolved the index ++ * they expect, instead of guessing between a stale connection, a different ++ * repo/branch, or a genuine build failure. MCP-`query`-only today — never ++ * forwarded into the HTTP `/api/search` response (see that call site). + */ +-const redactPaths = (reason: string): string => +- reason.replace(/(?:[A-Za-z]:\\|\/)[^\s'"]+/g, ''); ++export interface FtsWarningContext { ++ repoName: string; ++ branch?: string; ++ indexedAt?: string; ++ /** Already redacted by the caller (e.g. via {@link redactPaths} on a captured query error). */ ++ lastErrorRedacted?: string; ++} ++ ++/** The repo/branch/indexed-at portion shared by both warning-context formatters below. */ ++const formatResolvedSuffix = (context: FtsWarningContext): string => { ++ const branchSuffix = context.branch ? `/branch:${context.branch}` : ''; ++ const indexedSuffix = context.indexedAt ? `, indexed ${context.indexedAt}` : ''; ++ return `${context.repoName}${branchSuffix}${indexedSuffix}`; ++}; ++ ++const formatWarningContext = (context: FtsWarningContext): string => { ++ const errorSuffix = context.lastErrorRedacted ? `; last error: ${context.lastErrorRedacted}` : ''; ++ return ` (resolved: ${formatResolvedSuffix(context)}${errorSuffix})`; ++}; + + /** + * Warning attached to search responses when BM25/FTS is degraded. Prefers the + * live extension-load failure (with LadybugDB's real reason, #2374) over the + * generic indexes-missing message, so "indexes exist but the extension broke" + * is not misreported as missing indexes. ++ * ++ * `context`, when supplied, appends the resolved repo/branch/indexed-at (and ++ * redacted query-error detail, if captured) so a CLI/MCP mismatch — or a real ++ * query error masquerading as "indexes missing" — is visible in the warning ++ * text itself (#2767). Optional and additive: omitting it reproduces today's ++ * exact message. + */ +-export const ftsDegradedWarning = (): string => { ++export const ftsDegradedWarning = (context?: FtsWarningContext): string => { ++ const suffix = context ? formatWarningContext(context) : ''; + const fts = getExtensionCapabilities().find((c) => c.name === 'fts'); + if (fts && !fts.loaded) { + const reason = fts.reason ? redactPaths(fts.reason).replace(/\.$/, '') : undefined; +@@ -38,12 +83,33 @@ export const ftsDegradedWarning = (): string => { + return ( + 'FTS extension failed to load — keyword search degraded' + + (reason ? ` (${reason})` : '') + +- tail ++ tail + ++ suffix + ); + } +- return 'FTS indexes missing — keyword search degraded. Run: gitnexus analyze --repair-fts (or gitnexus analyze --force) to rebuild indexes.'; ++ return ( ++ 'FTS indexes missing — keyword search degraded. Run: gitnexus analyze --repair-fts ' + ++ '(or gitnexus analyze --force) to rebuild indexes.' + ++ suffix ++ ); + }; + ++/** ++ * Warning for when the FTS extension is loaded and indexes exist, but every ++ * configured table's query failed for a real, non-benign reason (timeout, ++ * connection reset, native fault) — as opposed to `ftsDegradedWarning`'s ++ * missing-index case. `--repair-fts` will not fix a query/connection error, ++ * so this deliberately does NOT suggest it: reusing the missing-index ++ * message here would reproduce, for this cause, the exact misleading ++ * "run --repair-fts" guidance #2767 itself was about (tri-review NEW-1). ++ */ ++export const ftsQueryFailedWarning = (context: FtsWarningContext): string => ++ 'FTS keyword search failed — every configured index query returned an error' + ++ (context.lastErrorRedacted ? ` (${context.lastErrorRedacted})` : '') + ++ '; results do not include keyword matches. This is not a missing-index ' + ++ 'condition — see server logs for details.' + ++ ` (resolved: ${formatResolvedSuffix(context)})`; ++ + // Stemmers shipped by the LadybugDB FTS extension. Mirrors the lowercase token + // set in the extension bundled with @ladybugdb/core 0.18.x (see package.json). + // Keep in sync on a LadybugDB minor bump — a value here that the installed +diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts +index 694483a6d..c4e9d12a9 100644 +--- a/gitnexus/src/mcp/local/local-backend.ts ++++ b/gitnexus/src/mcp/local/local-backend.ts +@@ -63,7 +63,7 @@ import { + import { EMBEDDING_TABLE_NAME, EMBEDDING_INDEX_NAME } from '../../core/lbug/schema.js'; + import { getExactScanLimit } from '../../core/platform/capabilities.js'; + import { PhaseTimer } from '../../core/search/phase-timer.js'; +-import { ftsDegradedWarning } from '../../core/search/fts-indexes.js'; ++import { ftsDegradedWarning, ftsQueryFailedWarning } from '../../core/search/fts-indexes.js'; + import { + cjkSegmentationModeMismatch, + containsSegmentableCjkRun, +@@ -779,6 +779,13 @@ export function attachToolStaleness( + }; + } + ++/** tri-review Residual-2: see `LocalBackend.lastObservedPoolState`'s doc comment. */ ++interface PoolObservedState { ++ indexedAt?: string; ++ dbIdentity: Awaited>; ++ ftsStatus?: string; ++} ++ + export class LocalBackend { + private static readonly TOOL_STALENESS_TTL_MS = 5000; + private repos: Map = new Map(); +@@ -796,18 +803,30 @@ export class LocalBackend { + // the other; lbugPath is unique per flat/branch index. + private toolStalenessCache: Map }> = + new Map(); +- // Last meta.indexedAt observed for an open pool, keyed by lbugPath. Keyed by +- // pool (not stored on the handle) because branch handles are produced fresh +- // by applyBranchScope on every resolveRepo call, so mutating the handle would +- // not persist across calls and the staleness check would reinit forever +- // (#2106). +- private lastObservedIndexedAt: Map = new Map(); +- // #2614 F1: file identity of the lbug the pool last opened. An atomic swap or +- // an in-place incremental changes the inode; reiniting on that reinit-covers +- // the window where meta.indexedAt hasn't caught up (and the incremental case), +- // so a rebuilt index is never served stale even when the stamp looks current. +- private lastObservedDbIdentity: Map>> = +- new Map(); ++ // tri-review Residual-2: consolidates what were three parallel per-poolKey ++ // Maps (lastObservedIndexedAt / lastObservedDbIdentity / lastObservedFtsStatus) ++ // touched in lockstep at every call site below — one Map, one delete, one ++ // shape. Keyed by lbugPath (not stored on the repo handle) because branch ++ // handles are produced fresh by applyBranchScope on every resolveRepo call, ++ // so mutating the handle would not persist across calls and the staleness ++ // check would reinit forever (#2106). ++ // - `indexedAt`: last meta.indexedAt observed for an open pool. ++ // - `dbIdentity`: file identity of the lbug the pool last opened (#2614 F1) ++ // — an atomic swap or in-place incremental changes the inode; reiniting ++ // on that covers the window where meta.indexedAt hasn't caught up (and ++ // the incremental case), so a rebuilt index is never served stale even ++ // when the stamp looks current. ++ // - `ftsStatus`: last meta.capabilities.fts.status observed (#2767). ++ // `--repair-fts` intentionally never restamps `indexedAt` (it doesn't ++ // regenerate the graph), so this is the dedicated signal a warm session ++ // uses to notice a repair — independent of the file-identity heuristic, ++ // which the repair path also triggers but only incidentally. ++ private lastObservedPoolState: Map = new Map(); ++ /** Merge-patch one poolKey's observed state, preserving fields not passed. */ ++ private setObservedState(poolKey: string, patch: Partial): void { ++ const current = this.lastObservedPoolState.get(poolKey) ?? { dbIdentity: null }; ++ this.lastObservedPoolState.set(poolKey, { ...current, ...patch }); ++ } + private groupToolSvc: GroupService | null = null; + /** + * One-shot stderr warnings for sibling-clone drift, keyed by +@@ -1143,8 +1162,7 @@ export class LocalBackend { + this.initializedRepos.delete(key); + this.lastStalenessCheck.delete(key); + this.toolStalenessCache.delete(key); +- this.lastObservedIndexedAt.delete(key); +- this.lastObservedDbIdentity.delete(key); ++ this.lastObservedPoolState.delete(key); + this.reinitPromises.delete(key); + closeLbug(key).catch(() => {}); + } +@@ -1548,10 +1566,11 @@ export class LocalBackend { + // Reading the flat meta for a branch handle would compare the branch + // index's indexedAt against the primary's and thrash the pool (#2106). + const meta = await loadMeta(path.dirname(repo.lbugPath)); ++ const observedState = this.lastObservedPoolState.get(poolKey); + // Compare against the last indexedAt OBSERVED for this pool (keyed by + // lbugPath), not the handle's — branch handles are fresh spreads so a + // handle mutation would not persist and would reinit on every check. +- const observed = this.lastObservedIndexedAt.get(poolKey) ?? repo.indexedAt; ++ const observed = observedState?.indexedAt ?? repo.indexedAt; + const stampChanged = !!meta?.indexedAt && meta.indexedAt !== observed; + // #2614 F1: also reinit on a file-identity change. An atomic swap (or an + // in-place incremental) changes the lbug inode; keying only on +@@ -1559,10 +1578,19 @@ export class LocalBackend { + // latch on the old inode forever (its stamp already == meta.indexedAt). + const currentIdentity = await statDbIdentity(repo.lbugPath); + const identityChanged = dbIdentityChanged( +- this.lastObservedDbIdentity.get(poolKey) ?? null, ++ observedState?.dbIdentity ?? null, + currentIdentity, + ); +- if (stampChanged || identityChanged) { ++ // #2767: `--repair-fts` intentionally never restamps `indexedAt` (it ++ // doesn't regenerate the graph), so `stampChanged` alone can't notice ++ // a repair. `capabilities.fts.status` is the field repair-fts DOES ++ // write, so a change there is a third, independent reinit trigger — ++ // sibling to stampChanged/identityChanged, not a replacement for them ++ // (identityChanged still catches an in-place mutation even if the ++ // caps stamp were somehow missed). ++ const ftsStatus = meta?.capabilities?.fts?.status; ++ const ftsCapsChanged = observedState?.ftsStatus !== ftsStatus; ++ if (stampChanged || identityChanged || ftsCapsChanged) { + // Index was rebuilt/swapped — DELEGATE the close/reopen to the pool's + // initLbug, which refuses to evict (and close the shared Database) + // while a query is in flight (its checkedOut>0 guard). Calling +@@ -1571,17 +1599,22 @@ export class LocalBackend { + // reinitPromises to serialize concurrent detectors. + const reinit = (async () => { + try { +- // Advance the observed stamp regardless: a stamp change with an +- // unchanged file must not re-trigger on every check. +- if (meta?.indexedAt) this.lastObservedIndexedAt.set(poolKey, meta.indexedAt); + const reopened = await initLbug(poolKey, repo.lbugPath); ++ // tri-review NEW-7: advance the observed stamp/caps watermarks ++ // only AFTER initLbug completes, not before calling it — still ++ // regardless of `reopened` true/false (a stamp/caps change with ++ // an unchanged file must not re-trigger on every check), but if ++ // initLbug THROWS the watermark must stay at its old value so ++ // the next staleness check retries, instead of a failed reinit ++ // silently latching as "already applied" and never trying again. ++ const patch: Partial = { ftsStatus }; ++ if (meta?.indexedAt) patch.indexedAt = meta.indexedAt; + // Advance the observed IDENTITY only when the pool actually rolled + // over. If a query was in flight, initLbug served the current + // handle and returned false; leaving the identity divergent + // re-triggers the reopen on a later idle check instead of latching. +- if (reopened) { +- this.lastObservedDbIdentity.set(poolKey, await statDbIdentity(repo.lbugPath)); +- } ++ if (reopened) patch.dbIdentity = await statDbIdentity(repo.lbugPath); ++ this.setObservedState(poolKey, patch); + } finally { + this.reinitPromises.delete(poolKey); + } +@@ -1599,8 +1632,18 @@ export class LocalBackend { + try { + await initLbug(poolKey, repo.lbugPath); + this.initializedRepos.add(poolKey); +- this.lastObservedIndexedAt.set(poolKey, repo.indexedAt); +- this.lastObservedDbIdentity.set(poolKey, await statDbIdentity(repo.lbugPath)); ++ // #2767: ftsStatus is deliberately left unset (undefined) here rather ++ // than issuing an extra loadMeta read — every tool call already routes ++ // through ensureInitialized, so an extra per-cold-init read adds up, ++ // and the cost of skipping it is negligible: at most one redundant ++ // initLbug call on the first warm check (initLbug itself no-ops ++ // cheaply via a single fs.stat when the file identity is actually ++ // unchanged, per pool-adapter.ts's own "unchanged → reuse" guard), not ++ // a real reopen. ++ this.setObservedState(poolKey, { ++ indexedAt: repo.indexedAt, ++ dbIdentity: await statDbIdentity(repo.lbugPath), ++ }); + } catch (err: any) { + // If lock error, mark as not initialized so next call retries + this.initializedRepos.delete(poolKey); +@@ -2047,6 +2090,21 @@ export class LocalBackend { + // unavailable the search helper may return an unexpected shape. + const bm25Results = bm25SearchResult?.results ?? []; + const ftsUsed = bm25SearchResult?.ftsUsed ?? false; ++ // #2767: log every non-benign per-table FTS query error server-side, ++ // regardless of whether OTHER tables succeeded — previously a real error ++ // on N-1 of N tables while one succeeded left zero diagnostic trail. ++ const ftsQueryErrors = bm25SearchResult?.nonBenignErrors; ++ if (ftsQueryErrors) { ++ // tri-review NEW-5: these strings are already classified non-benign by ++ // classifyFtsQueryError — do NOT route them through logQueryError, ++ // whose own broader, unanchored isBenignMissingTableError regex (any ++ // "does not exist" substring, anywhere) could disagree and silently ++ // demote an already-flagged real error to debug, undercutting the ++ // severity signal this classification exists to preserve. ++ for (const err of ftsQueryErrors) { ++ logger.warn({ context: 'query:fts-search', err }, 'GitNexus query failed (degraded)'); ++ } ++ } + + // Merge via reciprocal rank fusion + timer.start('merge'); +@@ -2326,7 +2384,37 @@ export class LocalBackend { + // path, leaving the success-path response shape byte-identical. + const warnings: string[] = []; + if (!ftsUsed) { +- warnings.push(ftsDegradedWarning()); ++ // #2767: attach what THIS session resolved (repo/branch/indexed-at) so a ++ // CLI/MCP mismatch is visible in the warning itself rather than requiring ++ // a separate debugging round-trip. tri-review NEW-3: `indexedAt` reads ++ // from `lastObservedPoolState` (kept current by ensureInitialized's ++ // staleness check, including a same-call reinit) rather than the `repo` ++ // handle resolved before that check ran — a warm backend that just ++ // reopened against a newer on-disk index must not warn with stale ++ // metadata. No extra I/O: the map is already maintained per-request. ++ const warningContext = { ++ repoName: repo.name, ++ branch: repo.branch, ++ indexedAt: this.lastObservedPoolState.get(repo.lbugPath)?.indexedAt ?? repo.indexedAt, ++ }; ++ // tri-review NEW-1: every table failing for a REAL error (timeout, ++ // connection reset) is not a missing-index condition — `ftsDegradedWarning`'s ++ // "run --repair-fts" headline won't fix it. Route to a dedicated message ++ // instead of burying the real cause as a trailing suffix on bad advice. ++ warnings.push( ++ ftsQueryErrors ++ ? ftsQueryFailedWarning({ ...warningContext, lastErrorRedacted: ftsQueryErrors[0] }) ++ : ftsDegradedWarning(warningContext), ++ ); ++ } else if (ftsQueryErrors) { ++ // #2767: at least one FTS table succeeded (ftsUsed=true) but another ++ // hit a real, non-benign error — results may be silently missing ++ // matches from that table with no signal, the same "partial success" ++ // shape the enrichmentDegraded branch below already surfaces. Mirror ++ // that convention instead of only logging server-side. ++ warnings.push( ++ `FTS keyword search partially failed — ${ftsQueryErrors.length} of the configured indexes hit a query error and were skipped; results may be missing matches from those node types (see server logs).`, ++ ); + } + // #2331: a CJK query against a server process resolving + // GITNEXUS_FTS_CJK_SEGMENTATION to 'none' silently misses sub-phrase +@@ -2403,6 +2491,10 @@ export class LocalBackend { + 'Symbol enrichment partially failed — some process/cohesion/content data may be missing from these results (see server logs).', + ); + } ++ // #2767: a partial FTS failure (some tables ok, one or more real errors) ++ // is as much a "results may be incomplete" signal as enrichmentDegraded — ++ // flag it the same way rather than only via the warning string. ++ const ftsPartial = ftsUsed && !!ftsQueryErrors; + + return { + processes, +@@ -2410,7 +2502,7 @@ export class LocalBackend { + definitions: definitions.slice(0, 20), // cap standalone definitions + timing, + ...(warnings.length > 0 && { warning: warnings.join(' ') }), +- ...(enrichmentDegraded && { partial: true }), ++ ...((enrichmentDegraded || ftsPartial) && { partial: true }), + }; + } + +@@ -2421,7 +2513,7 @@ export class LocalBackend { + repo: RepoHandle, + query: string, + limit: number, +- ): Promise<{ results: any[]; ftsUsed: boolean }> { ++ ): Promise<{ results: any[]; ftsUsed: boolean; nonBenignErrors?: string[] }> { + let searchFTSFromLbug; + try { + ({ searchFTSFromLbug } = await import('../../core/search/bm25-index.js')); +@@ -2453,6 +2545,7 @@ export class LocalBackend { + // could be undefined when the FTS extension is unavailable in the MCP process. + const bm25Results = ftsResponse?.results ?? []; + const ftsUsed = ftsResponse?.ftsAvailable ?? false; ++ const nonBenignErrors = ftsResponse?.nonBenignErrors; + + const results: any[] = []; + +@@ -2524,7 +2617,7 @@ export class LocalBackend { + } + } + +- return { results, ftsUsed }; ++ return { results, ftsUsed, ...(nonBenignErrors && { nonBenignErrors }) }; + } + + /** +diff --git a/gitnexus/src/server/api.ts b/gitnexus/src/server/api.ts +index 8d3d0aeda..031f148c4 100644 +--- a/gitnexus/src/server/api.ts ++++ b/gitnexus/src/server/api.ts +@@ -1833,8 +1833,16 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => + }, + pendingNodeIds: string[], + ): Promise => { ++ // tri-review NEW-2: re-read immediately before writing (mirrors ++ // the pattern in run-analyze.ts's --repair-fts stamp) instead of ++ // spreading the stale `embeddingMeta` snapshot captured once at ++ // job start. This job can run up to EMBED_TIMEOUT_MS (30 min); ++ // without a fresh read, a concurrent writer's update (e.g. a ++ // --repair-fts capability stamp) would be silently reverted on ++ // every checkpoint save for the job's whole lifetime. ++ const latestMeta = (await loadMeta(entry.storagePath)) ?? embeddingMeta; + embeddingMeta = { +- ...embeddingMeta, ++ ...latestMeta, + embeddingCheckpoint: { + at: new Date().toISOString(), + ...checkpoint, +@@ -1896,7 +1904,9 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => + // handles this during process exit, but the server keeps the + // connection open for other routes — a CHECKPOINT is enough. + await flushWAL(); +- embeddingMeta = { ...embeddingMeta, embeddingCheckpoint: undefined }; ++ // Same re-read-before-write reasoning as saveEmbeddingCheckpoint above. ++ const finalMeta = (await loadMeta(entry.storagePath)) ?? embeddingMeta; ++ embeddingMeta = { ...finalMeta, embeddingCheckpoint: undefined }; + await saveMeta(entry.storagePath, embeddingMeta); + }); + +diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts +index 6b8b6f19b..7ddc84bb1 100644 +--- a/gitnexus/src/storage/repo-manager.ts ++++ b/gitnexus/src/storage/repo-manager.ts +@@ -187,9 +187,12 @@ export interface RepoMeta { + * the meta literal in run-analyze.ts — typed here so the stamp site is + * compile-checked; tri-review 4669518496 P1/U3: `vectorSearch.status` + * must never claim 'vector-index' unless the run verified or recreated +- * the HNSW index). Forensic today — no programmatic readers (`doctor` +- * prints platform-derived capabilities, query routing never consults +- * meta). The status unions mirror `CapabilityStatus` / ++ * the HNSW index). `fts.status` gained its first programmatic reader in ++ * #2767: `LocalBackend.ensureInitialized()` compares it against the ++ * warm connection pool's last-observed value as the dedicated signal ++ * that `--repair-fts` changed FTS availability (`doctor` still prints ++ * platform-derived capabilities separately; `graph`/`vectorSearch` remain ++ * forensic-only). The status unions mirror `CapabilityStatus` / + * `SemanticSearchMode` in core/platform/capabilities.ts; inlined to keep + * storage/ free of a core/ type dependency. + */ +diff --git a/gitnexus/test/integration/fts-repair-warm-session.test.ts b/gitnexus/test/integration/fts-repair-warm-session.test.ts +new file mode 100644 +index 000000000..2550ba874 +--- /dev/null ++++ b/gitnexus/test/integration/fts-repair-warm-session.test.ts +@@ -0,0 +1,169 @@ ++/** ++ * Integration test for issue #2767: the MCP `query` tool reported "FTS ++ * indexes missing" against an index the CLI could search successfully, ++ * because a long-lived MCP session's pooled read-only connection had no ++ * reliable signal that `gitnexus analyze --repair-fts` changed FTS ++ * availability (repair-fts intentionally never restamps `indexedAt`). ++ * ++ * Everything real: a real writable LadybugDB session builds the initial ++ * index WITHOUT FTS (the exact shape implied by the original report — FTS ++ * built later), a real `LocalBackend` resolves it via the real registry and ++ * issues a real `query` tool call through the real connection pool, then a ++ * SEPARATE real writable session performs the repair (real ++ * `createSearchFTSIndexes`, real `saveMeta` capability stamp — the same ++ * production functions `--repair-fts` calls), and the SAME still-warm ++ * `LocalBackend` instance re-queries without any restart. ++ */ ++import { describe, it, expect, beforeEach, afterEach } from 'vitest'; ++import path from 'node:path'; ++import { createTempDir } from '../helpers/test-db.js'; ++import { resolveAnalyzeInstallPolicy } from '../../src/core/lbug/extension-loader.js'; ++import { ++ getStoragePaths, ++ registerRepo, ++ saveMeta, ++ type RepoMeta, ++} from '../../src/storage/repo-manager.js'; ++import { closeLbug as poolClose } from '../../src/core/lbug/pool-adapter.js'; ++import { LocalBackend } from '../../src/mcp/local/local-backend.js'; ++ ++const REQUIRE_FTS = process.env.GITNEXUS_REQUIRE_FTS === '1'; ++ ++type QueryResult = { ++ error?: unknown; ++ warning?: string; ++ definitions?: Array<{ id: string }>; ++ process_symbols?: Array<{ id: string }>; ++}; ++ ++const matchedIds = (r: QueryResult): string[] => ++ [...(r.process_symbols ?? []), ...(r.definitions ?? [])].map((s) => s.id); ++ ++const ftsMissing = (r: QueryResult): boolean => ++ typeof r.warning === 'string' && /FTS indexes missing/i.test(r.warning); ++ ++/** ++ * Poll the SAME warm `LocalBackend` until it stops reporting FTS-missing, or ++ * the deadline passes. Exercises the real 5s staleness-check throttle ++ * (`ensureInitialized`) rather than sleeping-and-hoping or reaching into ++ * backend internals to bypass it — proves the fix holds within the actual ++ * production timing window. ++ */ ++async function waitForFtsRecognized( ++ backend: LocalBackend, ++ query: string, ++ // Production throttle is 5s (`lastStalenessCheck`); this deadline leaves a ++ // generous margin beyond it for a loaded CI runner, per review feedback ++ // that the original 7s deadline left only ~2s of slack (#2767). ++ timeoutMs = 15000, ++ intervalMs = 300, ++): Promise { ++ const deadline = Date.now() + timeoutMs; ++ let last: QueryResult; ++ do { ++ last = await backend.callTool('query', { query }); ++ if (!ftsMissing(last)) return last; ++ await new Promise((resolve) => setTimeout(resolve, intervalMs)); ++ } while (Date.now() < deadline); ++ return last!; ++} ++ ++describe('warm MCP session observes an in-place --repair-fts rebuild (#2767)', () => { ++ let tmpHandle: Awaited>; ++ let repoPath: string; ++ let storagePath: string; ++ let lbugPath: string; ++ let savedHome: string | undefined; ++ ++ beforeEach(async () => { ++ tmpHandle = await createTempDir('gnx-fts-repair-warm-'); ++ repoPath = tmpHandle.dbPath; ++ savedHome = process.env.GITNEXUS_HOME; ++ process.env.GITNEXUS_HOME = path.join(repoPath, '.gitnexus-home'); ++ ({ storagePath, lbugPath } = getStoragePaths(repoPath)); ++ }); ++ ++ afterEach(async () => { ++ await poolClose(lbugPath).catch(() => {}); ++ if (savedHome === undefined) delete process.env.GITNEXUS_HOME; ++ else process.env.GITNEXUS_HOME = savedHome; ++ await tmpHandle.cleanup(); ++ }); ++ ++ it( ++ 'a warm session transitions from FTS-unavailable to FTS-available without restarting, after an out-of-band --repair-fts', ++ { timeout: 60_000 }, ++ async (ctx) => { ++ const adapter = await import('../../src/core/lbug/lbug-adapter.js'); ++ const { createSearchFTSIndexes } = await import('../../src/core/search/fts-indexes.js'); ++ ++ // ── Step 1: build the index WITHOUT FTS (analyzed before repair) ──── ++ await adapter.initLbug(lbugPath); ++ ++ const ftsAvailable = await adapter.loadFTSExtension(undefined, { ++ policy: resolveAnalyzeInstallPolicy(), ++ }); ++ if (!ftsAvailable) { ++ if (REQUIRE_FTS) { ++ throw new Error( ++ 'FTS extension is required (GITNEXUS_REQUIRE_FTS=1) but could not be loaded — ' + ++ 'this FTS-dependent integration test must not be silently skipped in CI.', ++ ); ++ } ++ await adapter.closeLbug(); ++ ctx.skip(); ++ return; ++ } ++ ++ await adapter.executeQuery( ++ `CREATE (n:Function {id: 'func:login', name: 'login', filePath: 'src/auth.ts', startLine: 1, endLine: 3, content: 'function login() { return true; }'})`, ++ ); ++ await adapter.flushWAL(); ++ await adapter.closeLbug(); ++ ++ const indexedAt = new Date().toISOString(); ++ const baseMeta: RepoMeta = { ++ repoPath, ++ lastCommit: 'c1', ++ indexedAt, ++ stats: { files: 1, nodes: 1 }, ++ capabilities: { ++ graph: { provider: 'ladybugdb', status: 'available' }, ++ fts: { provider: 'ladybugdb-fts', status: 'unavailable' }, ++ vectorSearch: { provider: 'exact-scan', status: 'unavailable', exactScanLimit: 0 }, ++ }, ++ }; ++ await saveMeta(storagePath, baseMeta); ++ await registerRepo(repoPath, baseMeta, { name: 'test-repo' }); ++ ++ // ── Step 2: a real warm LocalBackend observes "FTS unavailable" ───── ++ const backend = new LocalBackend(); ++ await backend.init(); ++ const before = await backend.callTool('query', { query: 'login' }); ++ expect(before.error).toBeUndefined(); ++ expect(ftsMissing(before)).toBe(true); ++ ++ // ── Step 3: out-of-band --repair-fts (separate writable session) ──── ++ // Same production functions the repair-fts branch of runFullAnalysis ++ // calls — real FTS build, then the #2767 capability-only meta stamp ++ // (indexedAt/lastCommit deliberately unchanged, R4). ++ await adapter.initLbug(lbugPath); ++ await createSearchFTSIndexes(); ++ await adapter.flushWAL(); ++ await adapter.closeLbug(); ++ await saveMeta(storagePath, { ++ ...baseMeta, ++ capabilities: { ++ ...baseMeta.capabilities!, ++ fts: { provider: 'ladybugdb-fts', status: 'available' }, ++ }, ++ }); ++ ++ // ── Step 4: the SAME still-warm backend re-queries — no restart ───── ++ const after = await waitForFtsRecognized(backend, 'login'); ++ expect(after.error).toBeUndefined(); ++ expect(ftsMissing(after)).toBe(false); ++ expect(matchedIds(after)).toContain('func:login'); ++ }, ++ ); ++}); +diff --git a/gitnexus/test/unit/bm25-search.test.ts b/gitnexus/test/unit/bm25-search.test.ts +index f6105918b..793cf7aae 100644 +--- a/gitnexus/test/unit/bm25-search.test.ts ++++ b/gitnexus/test/unit/bm25-search.test.ts +@@ -1,5 +1,7 @@ + import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + import { searchFTSFromLbug, type BM25SearchResult } from '../../src/core/search/bm25-index.js'; ++import { classifyFtsQueryError } from '../../src/core/lbug/lbug-adapter.js'; ++import { extensionManager, resetExtensionState } from '../../src/core/lbug/extension-loader.js'; + import { FTS_INDEXES } from '../../src/core/search/fts-schema.js'; + + vi.mock('../../src/core/lbug/lbug-adapter.js', async (importOriginal) => { +@@ -315,6 +317,183 @@ describe('BM25 search', () => { + }); + }); + ++ describe('classifyFtsQueryError (#2767)', () => { ++ it('classifies the real "doesn\'t have an index" message (confirmed against a live QUERY_FTS_INDEX call) as missing-index', () => { ++ expect( ++ classifyFtsQueryError( ++ "Prepare failed: Binder exception: Table File doesn't have an index with name file_fts.", ++ ), ++ ).toBe('missing-index'); ++ }); ++ ++ it('classifies the real "table does not exist" message (confirmed against a live QUERY_FTS_INDEX call on a nonexistent table) as missing-table, distinct from missing-index (tri-review NEW-6)', () => { ++ // Empirically confirmed: the table-missing message uses "does not ++ // exist", NOT "doesn't have an index with name" — a genuinely different ++ // phrasing from missing-index, not the same condition under two names. ++ // Conflating them was the exact bug: a corrupted/partial DB (table ++ // itself gone) would have been silently treated as the ordinary ++ // "index not built yet" case. ++ expect( ++ classifyFtsQueryError( ++ 'Prepare failed: Binder exception: Table TotallyNonexistentTable does not exist.', ++ ), ++ ).toBe('missing-table'); ++ }); ++ ++ it('classifies a Catalog-exception "does not exist" message as missing-table too (both exception classes covered)', () => { ++ expect(classifyFtsQueryError('Catalog exception: Table SomeTable does not exist.')).toBe( ++ 'missing-table', ++ ); ++ }); ++ ++ it('classifies the extension-unavailable Catalog exception as other, not benign (mirrors the confirmed DROP_FTS_INDEX shape for QUERY_FTS_INDEX)', () => { ++ // Message shape confirmed for DROP_FTS_INDEX in ++ // drop-fts-index-error-classification.test.ts; QUERY_FTS_INDEX would ++ // fail identically when the extension isn't loaded (same catalog). ++ expect( ++ classifyFtsQueryError( ++ "Catalog exception: function QUERY_FTS_INDEX is not defined. This function exists in the FTS extension. You can install and load the extension by running 'INSTALL FTS; LOAD EXTENSION FTS;'.", ++ ), ++ ).toBe('other'); ++ }); ++ ++ it('does not misclassify a real, differently-classed error that echoes the benign phrase in its body', () => { ++ // Adversarial case: a Runtime exception (not Binder/Catalog) that ++ // happens to echo the user's own search text — which could itself ++ // contain "does not exist" — must not be anchored away as benign. ++ expect( ++ classifyFtsQueryError( ++ 'Runtime exception: FTS query syntax error near "the config file does not exist here"', ++ ), ++ ).toBe('other'); ++ }); ++ ++ it('does not misclassify a real Binder-class error unrelated to a missing FTS index', () => { ++ expect(classifyFtsQueryError('Binder exception: column X does not match expected type')).toBe( ++ 'other', ++ ); ++ }); ++ ++ it('classifies any other message as other', () => { ++ expect(classifyFtsQueryError('Query execution timed out after 30000ms')).toBe('other'); ++ expect(classifyFtsQueryError('Connection pool exhausted')).toBe('other'); ++ }); ++ }); ++ ++ describe('MCP pool path — real vs benign FTS query errors (#2767)', () => { ++ const REPO = 'test-repo-error-classification'; ++ ++ beforeEach(() => { ++ mockExecuteParameterized.mockReset(); ++ }); ++ ++ it('a benign missing-index error on every table leaves nonBenignErrors unset (unchanged behavior)', async () => { ++ mockExecuteParameterized.mockRejectedValue( ++ new Error("Binder exception: Table Function doesn't have an index with name function_fts."), ++ ); ++ ++ const response = await searchFTSFromLbug('login', 5, REPO); ++ ++ expect(response.ftsAvailable).toBe(false); ++ expect(response.nonBenignErrors).toBeUndefined(); ++ }); ++ ++ it('a missing-table error (table itself gone, not just its FTS index) surfaces as non-benign — schema drift is not the ordinary degraded state (tri-review NEW-6)', async () => { ++ mockExecuteParameterized.mockRejectedValue( ++ new Error('Binder exception: Table Function does not exist.'), ++ ); ++ ++ const response = await searchFTSFromLbug('login', 5, REPO); ++ ++ expect(response.ftsAvailable).toBe(false); ++ expect(response.nonBenignErrors!.length).toBeGreaterThan(0); ++ }); ++ ++ it('a real error on every table surfaces it in nonBenignErrors, redacted', async () => { ++ mockExecuteParameterized.mockRejectedValue( ++ new Error( ++ 'Query execution failed: connection reset at /home/alice/.gitnexus/lbug/main.lbug', ++ ), ++ ); ++ ++ const response = await searchFTSFromLbug('login', 5, REPO); ++ ++ expect(response.ftsAvailable).toBe(false); ++ expect(response.nonBenignErrors).toBeDefined(); ++ expect(response.nonBenignErrors!.length).toBeGreaterThan(0); ++ expect(response.nonBenignErrors![0]).toContain('connection reset'); ++ expect(response.nonBenignErrors![0]).not.toMatch(/\/home\/alice/); ++ }); ++ ++ it('a real error on one table while another succeeds is still reported (partial-failure gap closed)', async () => { ++ let call = 0; ++ mockExecuteParameterized.mockImplementation(async (_repo: string, cypher: string) => { ++ call++; ++ if (cypher.includes("QUERY_FTS_INDEX('Function'")) { ++ throw new Error('Query execution timed out after 30000ms'); ++ } ++ if (cypher.includes("QUERY_FTS_INDEX('File'")) { ++ return [{ node: { filePath: 'src/index.ts', id: 'file:index' }, score: 3 }]; ++ } ++ return []; ++ }); ++ ++ const response = await searchFTSFromLbug('login', 5, REPO); ++ ++ // At least one table succeeded, so the client-visible availability ++ // signal and result set are unaffected (regression guard). ++ expect(response.ftsAvailable).toBe(true); ++ expect(response.results.length).toBeGreaterThan(0); ++ // But the real error on the OTHER table is not silently dropped. ++ expect(response.nonBenignErrors).toBeDefined(); ++ expect(response.nonBenignErrors![0]).toContain('timed out'); ++ expect(call).toBe(FTS_INDEXES.length); ++ }); ++ }); ++ ++ describe('short-circuits when the FTS extension is unavailable (tri-review NEW-4)', () => { ++ const REPO = 'test-repo-extension-unavailable'; ++ ++ afterEach(() => { ++ resetExtensionState(); ++ }); ++ ++ it('MCP pool path: skips per-table QUERY_FTS_INDEX calls and reports no nonBenignErrors when the extension failed to load', async () => { ++ await extensionManager.ensure( ++ vi.fn().mockRejectedValue(new Error('invalid ELF header.')), ++ 'fts', ++ 'FTS', ++ { policy: 'load-only' }, ++ ); ++ mockExecuteParameterized.mockReset(); ++ ++ const response = await searchFTSFromLbug('login', 5, REPO); ++ ++ // The expected degraded-capability state — not per-table query errors. ++ expect(response.ftsAvailable).toBe(false); ++ expect(response.nonBenignErrors).toBeUndefined(); ++ // No redundant round-trips to a pool that can't have FTS loaded. ++ expect(mockExecuteParameterized).not.toHaveBeenCalled(); ++ }); ++ ++ it('CLI/pipeline path (no repoId): also skips per-table calls and reports no nonBenignErrors — same expected state, same silence (fixes the pool-only guard a /simplify altitude pass caught)', async () => { ++ const { queryFTS } = await import('../../src/core/lbug/lbug-adapter.js'); ++ await extensionManager.ensure( ++ vi.fn().mockRejectedValue(new Error('invalid ELF header.')), ++ 'fts', ++ 'FTS', ++ { policy: 'load-only' }, ++ ); ++ vi.mocked(queryFTS).mockClear(); ++ ++ const response = await searchFTSFromLbug('login', 5); // no repoId → CLI/pipeline branch ++ ++ expect(response.ftsAvailable).toBe(false); ++ expect(response.nonBenignErrors).toBeUndefined(); ++ expect(vi.mocked(queryFTS)).not.toHaveBeenCalled(); ++ }); ++ }); ++ + describe('GITNEXUS_FTS_CJK_SEGMENTATION query-side transform (#2331)', () => { + const CJK_REPO = 'test-repo-cjk-query'; + +diff --git a/gitnexus/test/unit/ensure-initialized-reinit-watermark.test.ts b/gitnexus/test/unit/ensure-initialized-reinit-watermark.test.ts +new file mode 100644 +index 000000000..5dc089e56 +--- /dev/null ++++ b/gitnexus/test/unit/ensure-initialized-reinit-watermark.test.ts +@@ -0,0 +1,139 @@ ++import { describe, it, expect, vi, beforeEach } from 'vitest'; ++ ++// tri-review NEW-7: `lastObservedIndexedAt`/`lastObservedFtsStatus` used to be ++// advanced BEFORE `initLbug` was awaited, unlike `lastObservedDbIdentity` ++// (which is only advanced once `initLbug` confirms the pool rolled over). If ++// `initLbug` threw, the watermark had already been latched to the new value — ++// permanently hiding a failed reinit from every later staleness check, since ++// a subsequent comparison against that same watermark would see no change. ++// This isolates the fix: a poolKey with `identityChanged` always false (a ++// nonexistent lbugPath — no backstop from the file-identity signal) must ++// still retry after a transient `initLbug` failure. ++ ++const initLbugMock = vi.fn(); ++vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => { ++ const actual = await importOriginal(); ++ return { ++ ...actual, ++ initLbug: (...args: any[]) => initLbugMock(...args), ++ isLbugReady: vi.fn().mockReturnValue(true), ++ }; ++}); ++ ++const loadMetaMock = vi.fn(); ++vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => { ++ const actual = await importOriginal(); ++ return { ++ ...actual, ++ loadMeta: (...args: any[]) => loadMetaMock(...args), ++ }; ++}); ++ ++import { LocalBackend } from '../../src/mcp/local/local-backend'; ++ ++describe('ensureInitialized reinit watermark (tri-review NEW-7)', () => { ++ const poolKey = '/tmp/nonexistent-repo/.gitnexus/lbug'; ++ const repoHandle = { id: 'r1', name: 'r1', lbugPath: poolKey, indexedAt: 'v0' } as any; ++ let backend: any; ++ ++ beforeEach(() => { ++ vi.clearAllMocks(); ++ backend = new LocalBackend() as any; ++ // Seed the "warm, already initialized" precondition ensureInitialized ++ // requires to reach the staleness-check branch at all. ++ backend.initializedRepos.add(poolKey); ++ backend.lastStalenessCheck.set(poolKey, 0); // force past the 5s throttle ++ }); ++ ++ it('does not latch the fts-status watermark when initLbug throws, so the next staleness check retries', async () => { ++ loadMetaMock.mockResolvedValue({ ++ indexedAt: 'v1', ++ capabilities: { fts: { status: 'available' } }, ++ }); ++ initLbugMock.mockRejectedValueOnce(new Error('lock timeout')); ++ ++ await expect(backend.ensureInitialized(repoHandle)).rejects.toThrow('lock timeout'); ++ ++ // The failed reinit must NOT have advanced either watermark — a nonexistent ++ // lbugPath means dbIdentity never changes, so there is no other signal to ++ // fall back on for a retry. ++ expect(backend.lastObservedPoolState.get(poolKey)?.ftsStatus).toBeUndefined(); ++ expect(backend.lastObservedPoolState.get(poolKey)?.indexedAt).toBeUndefined(); ++ ++ // Second staleness check (throttle reset again) with initLbug now succeeding. ++ backend.lastStalenessCheck.set(poolKey, 0); ++ initLbugMock.mockResolvedValueOnce(false); // "no real reopen needed" — still a completed call ++ ++ await expect(backend.ensureInitialized(repoHandle)).resolves.toBeUndefined(); ++ ++ // The retry succeeded and the watermark is now current — proving the ++ // failed first attempt did not permanently suppress detection. ++ expect(backend.lastObservedPoolState.get(poolKey)?.ftsStatus).toBe('available'); ++ expect(backend.lastObservedPoolState.get(poolKey)?.indexedAt).toBe('v1'); ++ expect(initLbugMock).toHaveBeenCalledTimes(2); ++ }); ++}); ++ ++describe('ensureInitialized ftsCapsChanged trigger, isolated from identityChanged (tri-review Residual-4)', () => { ++ // The only existing coverage for this reinit trigger is the integration ++ // test in fts-repair-warm-session.test.ts, where a REAL --repair-fts run ++ // also mutates the lbug file — so identityChanged is confounded with ++ // ftsCapsChanged there, and it's impossible to tell from that test alone ++ // whether ftsCapsChanged is actually load-bearing. This isolates it: a ++ // nonexistent lbugPath means statDbIdentity always resolves null, so ++ // identityChanged is provably false on every check — the ONLY way a reinit ++ // can fire here is via ftsCapsChanged (indexedAt is held constant too, so ++ // stampChanged is also false). ++ const poolKey = '/tmp/nonexistent-repo-caps-only/.gitnexus/lbug'; ++ const repoHandle = { id: 'r2', name: 'r2', lbugPath: poolKey, indexedAt: 'same' } as any; ++ let backend: any; ++ ++ beforeEach(() => { ++ vi.clearAllMocks(); ++ backend = new LocalBackend() as any; ++ backend.initializedRepos.add(poolKey); ++ backend.lastStalenessCheck.set(poolKey, 0); ++ }); ++ ++ it('fires a reinit on a capabilities.fts.status change alone, with indexedAt and dbIdentity both unchanged', async () => { ++ // Seed a baseline observed state: same indexedAt the next loadMeta will ++ // report, a DIFFERENT ftsStatus, and dbIdentity null (matches what ++ // statDbIdentity will keep returning for this nonexistent path). ++ backend.lastObservedPoolState.set(poolKey, { ++ indexedAt: 'same', ++ ftsStatus: 'unavailable', ++ dbIdentity: null, ++ }); ++ ++ loadMetaMock.mockResolvedValue({ ++ indexedAt: 'same', // unchanged — stampChanged must be false ++ capabilities: { fts: { status: 'available' } }, // changed — the only live signal ++ }); ++ initLbugMock.mockResolvedValueOnce(true); ++ ++ await backend.ensureInitialized(repoHandle); ++ ++ // A reinit only happens inside the `if (stampChanged || identityChanged ++ // || ftsCapsChanged)` branch — initLbug being called at all here proves ++ // ftsCapsChanged fired, since the other two provably could not have. ++ expect(initLbugMock).toHaveBeenCalledTimes(1); ++ expect(backend.lastObservedPoolState.get(poolKey)?.ftsStatus).toBe('available'); ++ }); ++ ++ it('does NOT fire a reinit when nothing observable changed (negative control)', async () => { ++ backend.lastObservedPoolState.set(poolKey, { ++ indexedAt: 'same', ++ ftsStatus: 'available', ++ dbIdentity: null, ++ }); ++ ++ loadMetaMock.mockResolvedValue({ ++ indexedAt: 'same', ++ capabilities: { fts: { status: 'available' } }, // same as observed ++ }); ++ ++ await backend.ensureInitialized(repoHandle); ++ ++ expect(initLbugMock).not.toHaveBeenCalled(); ++ }); ++}); +diff --git a/gitnexus/test/unit/fts-degraded-warning.test.ts b/gitnexus/test/unit/fts-degraded-warning.test.ts +index fe5cc0554..42425be99 100644 +--- a/gitnexus/test/unit/fts-degraded-warning.test.ts ++++ b/gitnexus/test/unit/fts-degraded-warning.test.ts +@@ -79,6 +79,28 @@ describe('ftsDegradedWarning (#2374)', () => { + expect(warning).toContain('not a valid Win32 application'); + }); + ++ it('fully redacts a Windows path containing a space in the username (tri-review Residual-3, was only partially redacted)', async () => { ++ await extensionManager.ensure( ++ vi ++ .fn() ++ .mockRejectedValue( ++ new Error( ++ "Failed to load library 'C:\\Users\\alice smith\\.lbdb\\extension\\0.18.0\\win_amd64\\fts\\libfts.lbug_extension': not a valid Win32 application", ++ ), ++ ), ++ 'fts', ++ 'FTS', ++ { policy: 'load-only' }, ++ ); ++ ++ const warning = ftsDegradedWarning(); ++ // Neither the drive-letter prefix NOR the tail after the space may leak. ++ expect(warning).not.toMatch(/C:\\Users\\/); ++ expect(warning).not.toContain('smith'); ++ expect(warning).not.toContain('alice'); ++ expect(warning).toContain('not a valid Win32 application'); ++ }); ++ + it('surfaces the runtime-install remedy, not reinstall, for a Windows missing-dependency error', async () => { + await extensionManager.ensure( + vi +@@ -139,3 +161,68 @@ describe('ftsDegradedWarning (#2374)', () => { + expect(ftsDegradedWarning()).toMatch(/Visual C\+\+/); + }); + }); ++ ++describe('ftsDegradedWarning resolved-repo context (#2767)', () => { ++ it('omits the context suffix entirely when no context is passed (unchanged message)', async () => { ++ await extensionManager.ensure(vi.fn().mockResolvedValue({}), 'fts', 'FTS', { ++ policy: 'load-only', ++ }); ++ ++ expect(ftsDegradedWarning()).toBe( ++ 'FTS indexes missing — keyword search degraded. Run: gitnexus analyze --repair-fts (or gitnexus analyze --force) to rebuild indexes.', ++ ); ++ }); ++ ++ it('appends the resolved repo name and indexed-at on the indexes-missing branch', async () => { ++ await extensionManager.ensure(vi.fn().mockResolvedValue({}), 'fts', 'FTS', { ++ policy: 'load-only', ++ }); ++ ++ const warning = ftsDegradedWarning({ ++ repoName: 'myrepo', ++ indexedAt: '2026-07-30T12:00:00.000Z', ++ }); ++ expect(warning).toContain('FTS indexes missing'); ++ expect(warning).toContain('resolved: myrepo'); ++ expect(warning).toContain('indexed 2026-07-30T12:00:00.000Z'); ++ }); ++ ++ it('includes the branch label when the resolved handle is branch-scoped', async () => { ++ await extensionManager.ensure(vi.fn().mockResolvedValue({}), 'fts', 'FTS', { ++ policy: 'load-only', ++ }); ++ ++ const warning = ftsDegradedWarning({ repoName: 'myrepo', branch: 'feature/x' }); ++ expect(warning).toContain('branch:feature/x'); ++ }); ++ ++ it('never leaks an absolute path via the context suffix', async () => { ++ await extensionManager.ensure(vi.fn().mockResolvedValue({}), 'fts', 'FTS', { ++ policy: 'load-only', ++ }); ++ ++ const warning = ftsDegradedWarning({ ++ repoName: 'myrepo', ++ lastErrorRedacted: 'connection reset', ++ }); ++ expect(warning).not.toMatch(/\/home\/|\/Users\/|C:\\Users\\/); ++ expect(warning).toContain('last error: connection reset'); ++ }); ++ ++ it('also renders the context suffix on the extension-failed-to-load branch', async () => { ++ await extensionManager.ensure( ++ vi.fn().mockRejectedValue(new Error('invalid ELF header.')), ++ 'fts', ++ 'FTS', ++ { policy: 'load-only' }, ++ ); ++ ++ const warning = ftsDegradedWarning({ ++ repoName: 'myrepo', ++ indexedAt: '2026-07-30T12:00:00.000Z', ++ }); ++ expect(warning).toContain('FTS extension failed to load'); ++ expect(warning).toContain('resolved: myrepo'); ++ expect(warning).toContain('indexed 2026-07-30T12:00:00.000Z'); ++ }); ++}); +diff --git a/gitnexus/test/unit/query-degraded-signal.test.ts b/gitnexus/test/unit/query-degraded-signal.test.ts +index e72b9a7ce..a7ed807e8 100644 +--- a/gitnexus/test/unit/query-degraded-signal.test.ts ++++ b/gitnexus/test/unit/query-degraded-signal.test.ts +@@ -46,7 +46,7 @@ import { LocalBackend } from '../../src/mcp/local/local-backend'; + // A backend whose hybrid search yields exactly one matched symbol, so the + // enrichment chunk loop runs and can be made to fail. `ftsUsed` is parameterized + // so we can exercise the FTS-missing + enrichment-degraded composition. +-function makeBackend(ftsUsed = true): LocalBackend { ++function makeBackend(ftsUsed = true, nonBenignErrors?: string[]): LocalBackend { + const backend = new LocalBackend(); + const repoHandle = { + id: 'repo1', +@@ -68,7 +68,9 @@ function makeBackend(ftsUsed = true): LocalBackend { + startLine: 1, + endLine: 2, + }; +- (backend as any).bm25Search = vi.fn().mockResolvedValue({ results: [sym], ftsUsed }); ++ (backend as any).bm25Search = vi ++ .fn() ++ .mockResolvedValue({ results: [sym], ftsUsed, ...(nonBenignErrors && { nonBenignErrors }) }); + (backend as any).semanticSearch = vi.fn().mockResolvedValue([]); + return { backend, repoHandle } as any; + } +@@ -133,6 +135,102 @@ describe('query: degraded-enrichment signal', () => { + expect(result.warning.toLowerCase()).toContain('enrichment'); + }); + ++ it('a non-benign FTS query error is logged AND surfaced as a partial-result warning even when FTS overall succeeded (#2767)', async () => { ++ const cap: LoggerCapture = _captureLogger(); ++ try { ++ const b = makeBackend(true, ['Query execution timed out after 30000ms']); ++ executeParameterizedMock.mockResolvedValue([]); ++ ++ const result = await runQuery(b); ++ ++ // Real error must reach the server log (previously silent)... ++ expect(result).not.toHaveProperty('error'); ++ const record = cap.records().find((r) => r.context === 'query:fts-search'); ++ expect(record).toBeDefined(); ++ // ...AND the client sees it: mirrors the enrichmentDegraded convention ++ // for "some succeeded, one genuinely failed" (#2767). ++ expect(result.partial).toBe(true); ++ expect(result.warning).toMatch(/FTS keyword search partially failed/); ++ } finally { ++ cap.restore(); ++ } ++ }); ++ ++ it('logs an already-classified non-benign FTS error at warn even when it echoes "does not exist" (tri-review NEW-5)', async () => { ++ const cap: LoggerCapture = _captureLogger(); // default 'info' level — a debug-level record would be invisible here ++ try { ++ // Adversarial shape from classifyFtsQueryError's own doc comment: a real ++ // error whose body happens to contain the benign phrase. It's already in ++ // nonBenignErrors (classifyFtsQueryError correctly refused to call it ++ // benign), so it must not be silently re-demoted to debug by a second, ++ // broader classifier on the logging path. ++ const b = makeBackend(true, [ ++ 'Runtime exception: FTS query syntax error near "the config file does not exist here"', ++ ]); ++ executeParameterizedMock.mockResolvedValue([]); ++ ++ await runQuery(b); ++ ++ const record = cap.records().find((r) => r.context === 'query:fts-search'); ++ expect(record).toBeDefined(); ++ expect(record!.level).toBeGreaterThanOrEqual(40); // pino 'warn', not 'debug' (20) ++ } finally { ++ cap.restore(); ++ } ++ }); ++ ++ it('does not flag partial when FTS fully succeeds with no query errors', async () => { ++ const b = makeBackend(true); ++ executeParameterizedMock.mockResolvedValue([]); ++ ++ const result = await runQuery(b); ++ ++ expect(result.partial).toBeUndefined(); ++ expect(result.warning).toBeUndefined(); ++ }); ++ ++ it('surfaces a dedicated query-failed warning (not missing-index advice) when every table failed for a real error (tri-review NEW-1)', async () => { ++ const b = makeBackend(false, ['connection reset']); ++ executeParameterizedMock.mockResolvedValue([]); ++ ++ const result = await runQuery(b); ++ ++ // The indexes are NOT missing here — --repair-fts would not help, so the ++ // misleading missing-index advice must not be the headline. ++ expect(result.warning).not.toContain('FTS indexes missing'); ++ expect(result.warning).not.toContain('repair-fts'); ++ expect(result.warning).toContain('FTS keyword search failed'); ++ expect(result.warning).toContain('connection reset'); ++ }); ++ ++ it('the FTS-missing warning uses the freshly-observed indexedAt, not a stale cached repo handle (tri-review NEW-3)', async () => { ++ const b = makeBackend(false); // FTS unavailable ++ executeParameterizedMock.mockResolvedValue([]); ++ // Simulate ensureInitialized having just reinit'd against a newer index — ++ // it keeps lastObservedPoolState current but never mutates the caller's ++ // `repo` handle (a fresh spread per call), which still reads the old value. ++ (b.backend as any).lastObservedPoolState.set('/tmp/repo/.gitnexus/lbug', { ++ indexedAt: 'fresher-than-repo-handle', ++ dbIdentity: null, ++ }); ++ ++ const result = await runQuery(b); ++ ++ expect(result.warning).toContain('indexed fresher-than-repo-handle'); ++ expect(result.warning).not.toContain('indexed now'); // the stale repo.indexedAt value ++ }); ++ ++ it('the FTS-missing warning includes the resolved repo name and indexed-at (#2767)', async () => { ++ const b = makeBackend(false); // FTS unavailable ++ executeParameterizedMock.mockResolvedValue([]); ++ ++ const result = await runQuery(b); ++ ++ expect(result.warning).toContain('FTS indexes missing'); ++ expect(result.warning).toContain('resolved: repo1'); ++ expect(result.warning).toContain('indexed now'); ++ }); ++ + it('warns when a CJK query hits a server resolving segmentation to none (#2331)', async () => { + const b = makeBackend(true); + executeParameterizedMock.mockResolvedValue([]); +diff --git a/gitnexus/test/unit/run-analyze-fts-repair.test.ts b/gitnexus/test/unit/run-analyze-fts-repair.test.ts +index 3dc0be797..67b35fcbc 100644 +--- a/gitnexus/test/unit/run-analyze-fts-repair.test.ts ++++ b/gitnexus/test/unit/run-analyze-fts-repair.test.ts +@@ -259,6 +259,246 @@ describe('runFullAnalysis FTS repair and verification failure paths', () => { + } + }); + ++ const mockRepairSuccessLbugAdapter = (overrides: Record = {}) => ({ ++ initLbug: vi.fn(async () => undefined), ++ loadGraphToLbug: vi.fn(async () => undefined), ++ getLbugStats: vi.fn(async () => ({})), ++ executeQuery: vi.fn(async () => []), ++ executeWithReusedStatement: vi.fn(async () => []), ++ closeLbug: vi.fn(async () => undefined), ++ wipeLbugDbFiles: vi.fn(async () => undefined), ++ loadCachedEmbeddings: vi.fn(async () => ({ embeddingNodeIds: new Set(), embeddings: [] })), ++ deleteNodesForFile: vi.fn(async () => undefined), ++ deleteNodesForFiles: vi.fn(async () => undefined), ++ deleteAllCommunitiesAndProcesses: vi.fn(async () => undefined), ++ queryImporters: vi.fn(async () => []), ++ queryImportersBatch: vi.fn(async () => []), ++ loadFTSExtension: vi.fn(async () => true), ++ ...overrides, ++ }); ++ ++ it('--repair-fts stamps capabilities.fts.status while leaving indexedAt/lastCommit/runnerIdentity/stats byte-identical (#2767)', async () => { ++ vi.doMock('../../src/core/lbug/lbug-adapter.js', () => mockRepairSuccessLbugAdapter()); ++ vi.doMock('../../src/core/search/fts-indexes.js', () => ({ ++ initialiseSearchFTSStemmer: vi.fn(() => 'porter'), ++ createSearchFTSIndexes: vi.fn(async () => undefined), ++ verifySearchFTSIndexes: vi.fn(async () => []), ++ })); ++ vi.doMock('../../src/storage/repo-manager.js', async (importActual) => ({ ++ ...(await importActual()), ++ ensureGitNexusIgnored: vi.fn(async () => undefined), ++ })); ++ ++ const tmpRepo = await createTempDir('gitnexus-run-analyze-repair-stamp-'); ++ try { ++ const { storagePath, lbugPath } = getStoragePaths(tmpRepo.dbPath); ++ await fs.mkdir(storagePath, { recursive: true }); ++ const seededIndexedAt = new Date('2026-01-01T00:00:00.000Z').toISOString(); ++ const seeded: RepoMeta = { ++ repoPath: tmpRepo.dbPath, ++ lastCommit: 'abc123', ++ indexedAt: seededIndexedAt, ++ stats: { files: 7, nodes: 42, edges: 10 }, ++ runnerIdentity: { ++ source: { kind: 'source' as const, digest: 'src-digest' }, ++ build: { ++ kind: 'source' as const, ++ rootPath: '/x', ++ canonicalization: 'gitnexus-analyzer-build-v2', ++ digest: 'build-digest', ++ }, ++ dependencyRuntime: { ++ manifestPath: '/x/package.json', ++ lockfilePath: null, ++ canonicalization: 'gitnexus-analyzer-dependency-runtime-v4', ++ packageCount: 1, ++ artifactCount: 1, ++ digest: 'dep-digest', ++ }, ++ }, ++ capabilities: { ++ graph: { provider: 'ladybugdb', status: 'available' }, ++ fts: { provider: 'ladybugdb-fts', status: 'degraded' }, ++ vectorSearch: { provider: 'exact-scan', status: 'unavailable', exactScanLimit: 500 }, ++ }, ++ }; ++ await saveMeta(storagePath, seeded); ++ await createPlaceholderGraphStore(lbugPath); ++ ++ const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); ++ const result = await runFullAnalysis( ++ tmpRepo.dbPath, ++ { repairFts: true }, ++ { onProgress: () => {} }, ++ ); ++ expect(result.ftsRepairedOnly).toBe(true); ++ ++ const meta = JSON.parse(await fs.readFile(`${storagePath}/gitnexus.json`, 'utf-8')); ++ expect(meta.capabilities.fts.status).toBe('available'); ++ // Everything repair-fts must NOT touch stays byte-identical (R4). ++ expect(meta.indexedAt).toBe(seededIndexedAt); ++ expect(meta.lastCommit).toBe('abc123'); ++ expect(meta.runnerIdentity).toEqual(seeded.runnerIdentity); ++ expect(meta.stats).toEqual(seeded.stats); ++ // graph/vectorSearch, which repair-fts also never touches, pass through. ++ expect(meta.capabilities.graph).toEqual(seeded.capabilities!.graph); ++ expect(meta.capabilities.vectorSearch).toEqual(seeded.capabilities!.vectorSearch); ++ } finally { ++ await tmpRepo.cleanup(); ++ } ++ }); ++ ++ it('--repair-fts backfills a full capabilities object when the existing meta predates the field entirely (#2767)', async () => { ++ vi.doMock('../../src/core/lbug/lbug-adapter.js', () => mockRepairSuccessLbugAdapter()); ++ vi.doMock('../../src/core/search/fts-indexes.js', () => ({ ++ initialiseSearchFTSStemmer: vi.fn(() => 'porter'), ++ createSearchFTSIndexes: vi.fn(async () => undefined), ++ verifySearchFTSIndexes: vi.fn(async () => []), ++ })); ++ vi.doMock('../../src/storage/repo-manager.js', async (importActual) => ({ ++ ...(await importActual()), ++ ensureGitNexusIgnored: vi.fn(async () => undefined), ++ })); ++ ++ const tmpRepo = await createTempDir('gitnexus-run-analyze-repair-legacy-meta-'); ++ try { ++ const { storagePath, lbugPath } = getStoragePaths(tmpRepo.dbPath); ++ await fs.mkdir(storagePath, { recursive: true }); ++ // Legacy shape: no `capabilities` key at all (pre-#2658 meta.json). ++ await saveMeta(storagePath, { ++ repoPath: tmpRepo.dbPath, ++ lastCommit: '', ++ indexedAt: new Date().toISOString(), ++ stats: {}, ++ }); ++ await createPlaceholderGraphStore(lbugPath); ++ ++ const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); ++ // Must not throw — a partial-capabilities spread over `undefined` would ++ // otherwise violate RepoMeta.capabilities' required sub-fields. ++ const result = await runFullAnalysis( ++ tmpRepo.dbPath, ++ { repairFts: true }, ++ { onProgress: () => {} }, ++ ); ++ expect(result.ftsRepairedOnly).toBe(true); ++ ++ const meta = JSON.parse(await fs.readFile(`${storagePath}/gitnexus.json`, 'utf-8')); ++ expect(meta.capabilities.fts.status).toBe('available'); ++ expect(meta.capabilities.graph).toBeDefined(); ++ expect(meta.capabilities.graph.status).toBe('available'); ++ expect(meta.capabilities.vectorSearch).toBeDefined(); ++ expect(meta.capabilities.vectorSearch.status).toBe('unavailable'); ++ expect(typeof meta.capabilities.vectorSearch.exactScanLimit).toBe('number'); ++ } finally { ++ await tmpRepo.cleanup(); ++ } ++ }); ++ ++ it('--repair-fts still reports success when the capability-stamp write fails (#2767)', async () => { ++ vi.doMock('../../src/core/lbug/lbug-adapter.js', () => mockRepairSuccessLbugAdapter()); ++ vi.doMock('../../src/core/search/fts-indexes.js', () => ({ ++ initialiseSearchFTSStemmer: vi.fn(() => 'porter'), ++ createSearchFTSIndexes: vi.fn(async () => undefined), ++ verifySearchFTSIndexes: vi.fn(async () => []), ++ })); ++ vi.doMock('../../src/storage/repo-manager.js', async (importActual) => ({ ++ ...(await importActual()), ++ ensureGitNexusIgnored: vi.fn(async () => undefined), ++ // Repair itself (createSearchFTSIndexes/verify) already succeeded by the ++ // time this fires — a write failure here must degrade, not fail the run. ++ saveMeta: vi.fn(async () => { ++ throw new Error('EACCES: permission denied'); ++ }), ++ })); ++ ++ const tmpRepo = await createTempDir('gitnexus-run-analyze-repair-stamp-write-fail-'); ++ try { ++ const { storagePath, lbugPath } = getStoragePaths(tmpRepo.dbPath); ++ await fs.mkdir(storagePath, { recursive: true }); ++ await saveMeta(storagePath, { ++ repoPath: tmpRepo.dbPath, ++ lastCommit: '', ++ indexedAt: new Date().toISOString(), ++ stats: {}, ++ }); ++ await createPlaceholderGraphStore(lbugPath); ++ ++ const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); ++ const logs: string[] = []; ++ const result = await runFullAnalysis( ++ tmpRepo.dbPath, ++ { repairFts: true }, ++ { onProgress: () => {}, onLog: (msg: string) => logs.push(msg) }, ++ ); ++ ++ expect(result.ftsRepairedOnly).toBe(true); ++ expect(logs.join('\n')).toMatch(/capability stamp write failed/i); ++ } finally { ++ await tmpRepo.cleanup(); ++ } ++ }); ++ ++ it('--repair-fts stamps onto the LATEST on-disk meta, not a snapshot from before the rebuild ran (#2767)', async () => { ++ // A concurrent writer (e.g. the HTTP server's background embedding ++ // checkpoint job) lands its own saveMeta while the FTS rebuild is in ++ // flight. The repair-fts stamp must not silently revert that write by ++ // basing itself on the `existingMeta` captured before the rebuild started. ++ const CONCURRENT_LAST_COMMIT = 'concurrent-writer-commit'; ++ let storagePathForConcurrentWrite = ''; ++ vi.doMock('../../src/core/lbug/lbug-adapter.js', () => mockRepairSuccessLbugAdapter()); ++ vi.doMock('../../src/core/search/fts-indexes.js', () => ({ ++ initialiseSearchFTSStemmer: vi.fn(() => 'porter'), ++ createSearchFTSIndexes: vi.fn(async () => { ++ // Simulate the concurrent writer landing mid-repair, via the test ++ // file's own top-level `saveMeta` import (bound before any ++ // vi.doMock call in this file, so it is always the real function). ++ await saveMeta(storagePathForConcurrentWrite, { ++ repoPath: '', ++ lastCommit: CONCURRENT_LAST_COMMIT, ++ indexedAt: new Date().toISOString(), ++ stats: { files: 999 }, ++ }); ++ }), ++ verifySearchFTSIndexes: vi.fn(async () => []), ++ })); ++ vi.doMock('../../src/storage/repo-manager.js', async (importActual) => ({ ++ ...(await importActual()), ++ ensureGitNexusIgnored: vi.fn(async () => undefined), ++ })); ++ ++ const tmpRepo = await createTempDir('gitnexus-run-analyze-repair-race-'); ++ try { ++ const { storagePath, lbugPath } = getStoragePaths(tmpRepo.dbPath); ++ storagePathForConcurrentWrite = storagePath; ++ await fs.mkdir(storagePath, { recursive: true }); ++ await saveMeta(storagePath, { ++ repoPath: tmpRepo.dbPath, ++ lastCommit: 'original-commit', ++ indexedAt: new Date().toISOString(), ++ stats: { files: 1 }, ++ }); ++ await createPlaceholderGraphStore(lbugPath); ++ ++ const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); ++ const result = await runFullAnalysis( ++ tmpRepo.dbPath, ++ { repairFts: true }, ++ { onProgress: () => {} }, ++ ); ++ expect(result.ftsRepairedOnly).toBe(true); ++ ++ const meta = JSON.parse(await fs.readFile(`${storagePath}/gitnexus.json`, 'utf-8')); ++ // The concurrent writer's update survives — the stamp did not revert it. ++ expect(meta.lastCommit).toBe(CONCURRENT_LAST_COMMIT); ++ expect(meta.stats).toEqual({ files: 999 }); ++ // The FTS stamp still landed on top of that latest state. ++ expect(meta.capabilities.fts.status).toBe('available'); ++ } finally { ++ await tmpRepo.cleanup(); ++ } ++ }); ++ + it('surfaces extension-unavailable errors from FTS index creation in repair mode', async () => { + vi.doMock('../../src/core/lbug/lbug-adapter.js', () => ({ + initLbug: vi.fn(async () => undefined), diff --git a/eval/workflow_bench/review_cases/pr-2794-defect.patch b/eval/workflow_bench/review_cases/pr-2794-defect.patch new file mode 100644 index 000000000..1c94f9ebb --- /dev/null +++ b/eval/workflow_bench/review_cases/pr-2794-defect.patch @@ -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, `` per #1564, or ``); 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 ? '' : hit === '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>; ++} ++ ++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>(); ++ // 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>(); ++ ++ 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(); ++ 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, ++ byMember: Map, ++ seen: Set, ++): 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(); +- for (const parsed of parsedFiles) { +- const scopesById = new Map(); +- 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 { inline namespace v1 { } }` */ ++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(); ++ }); ++}); diff --git a/eval/workflow_bench/review_cases/pr-3109-defect.patch b/eval/workflow_bench/review_cases/pr-3109-defect.patch deleted file mode 100644 index f1c0f4cc3..000000000 --- a/eval/workflow_bench/review_cases/pr-3109-defect.patch +++ /dev/null @@ -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; -- grep: (pattern: string, limit?: number) => Promise; -+ grep: ( -+ pattern: string, -+ limit?: number, -+ opts?: { fileFilter?: string; caseSensitive?: boolean }, -+ ) => Promise; - readFile: (filePath: string) => Promise; - } - -@@ -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 => { -+ opts?: GrepOptions, -+): Promise => { - 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); -+ 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): 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 -+ }); -+}); diff --git a/eval/workflow_bench/review_cases/pr-3111-defect.patch b/eval/workflow_bench/review_cases/pr-3111-defect.patch deleted file mode 100644 index 654a7e8cf..000000000 --- a/eval/workflow_bench/review_cases/pr-3111-defect.patch +++ /dev/null @@ -1,9376 +0,0 @@ -diff --git a/.claude/skills/gitnexus-cli/SKILL.md b/.claude/skills/gitnexus-cli/SKILL.md -index be02d92fd..170703439 100644 ---- a/.claude/skills/gitnexus-cli/SKILL.md -+++ b/.claude/skills/gitnexus-cli/SKILL.md -@@ -33,7 +33,7 @@ Run from the project root. This parses all source files, builds the knowledge gr - - For Spring runtime enrichment, pass a JSON bundle, one endpoint JSON file, or a directory containing endpoint files. Route evidence is authoritative only when `runtimeConfirmed === true`; `runtimeSource` records provenance and may also accompany `handler-conflict`. Env/configprops values are never persisted. - --Use `node .gitnexus/run.cjs analyze --watch` for a long-lived local Git repository. It performs an initial analysis, queues scanner-admitted file changes, and retries intact failed batches with bounded backoff. Watch refreshes update only the graph: they skip AGENTS.md / CLAUDE.md injection and standard skill installation, so run a one-shot `analyze` when those generated files need updating. Watch rejects one-shot or context-output flags including `--force`, embedding flags, `--skills`, `--default-branch`, `--skip-agents-md`, `--skip-skills`, `--no-stats`, `--self-commit`, `--index-only`, and `--skip-git`. It never pulls remotes. Scheduled remote clone/pull is a different command: `gitnexus auto-sync`. Bare `gitnexus watch` is reserved and does not start either job. Running MCP and `serve` processes periodically check for a published replacement and reopen it without a restart. MCP checks are throttled to once every five seconds, so a tool call before the next check can briefly use the previous index. -+Use `node .gitnexus/run.cjs analyze --watch` for a long-lived local Git repository. It performs an initial analysis, queues scanner-admitted file changes, and retries intact failed batches with bounded backoff. Watch refreshes update only the graph: they skip AGENTS.md / CLAUDE.md injection and standard skill installation, so run a one-shot `analyze` when those generated files need updating. Watch rejects one-shot or context-output flags including `--force`, embedding flags, `--skills`, `--default-branch`, `--skip-agents-md`, `--skip-skills`, `--no-stats`, `--self-commit`, `--index-only`, and `--skip-git`. It never pulls remotes. Running MCP and `serve` processes periodically check for a published replacement and reopen it without a restart. MCP checks are throttled to once every five seconds, so a tool call before the next check can briefly use the previous index. - - ### status — Check index freshness - -diff --git a/.gitignore b/.gitignore -index 3d125d475..e16544f71 100644 ---- a/.gitignore -+++ b/.gitignore -@@ -31,8 +31,6 @@ npm-debug.log* - - # Testing - coverage/ --.tmp-test/ --gitnexus/.tmp-test/ - - # Misc - *.local -diff --git a/Dockerfile.cli b/Dockerfile.cli -index 1488bee54..b42c22dad 100644 ---- a/Dockerfile.cli -+++ b/Dockerfile.cli -@@ -51,9 +51,8 @@ RUN npm run postinstall --prefix gitnexus - # node:22-bookworm-slim - FROM node:22-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e AS runtime - --# curl for the healthcheck; git for cloning; procps for watch process identity; --# ca-certificates for TLS verification. --RUN apt-get update && apt-get install -y --no-install-recommends curl git procps ca-certificates && rm -rf /var/lib/apt/lists/* \ -+# curl for the healthcheck; git for cloning; ca-certificates for TLS verification. -+RUN apt-get update && apt-get install -y --no-install-recommends curl git ca-certificates && rm -rf /var/lib/apt/lists/* \ - && rm -rf /usr/local/lib/node_modules/npm \ - && rm -rf /usr/local/lib/node_modules/corepack \ - && rm -f /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack -diff --git a/README.md b/README.md -index 0e0e2f616..f011447fd 100644 ---- a/README.md -+++ b/README.md -@@ -470,46 +470,6 @@ If embeddings are skipped on a large repository, the indexed graph likely exceed - - - --
--Keep remote repositories indexed with gitnexus auto-sync -- --`gitnexus auto-sync` clones or pulls configured repositories, analyzes new commits, and optionally syncs their group. It runs once immediately, then repeats on the configured interval. It runs in the foreground; use your process manager if it must survive a shell session. `gitnexus watch` is reserved and prints this split; it does not start auto-sync or local file watching. -- --```bash --# 1. Create the config once. It never overwrites an existing file. --gitnexus auto-sync init -- --# 2. Edit $GITNEXUS_HOME/watch_config.yml, then start it. --gitnexus auto-sync start # `gitnexus auto-sync` is equivalent --gitnexus auto-sync status --gitnexus auto-sync restart # Required after config changes --gitnexus auto-sync stop --gitnexus auto-sync reset # Clear failure state; leaves clones and indexes intact --``` -- --`GITNEXUS_HOME` defaults to `~/.gitnexus`. A minimal configuration: -- --```yaml --sync_interval_minutes: 10 --analyze_timeout: 5m --projects: -- - local_path: /absolute/path/to/clones -- branches: [main, master] -- overwrite_local_changes: false -- remote_urls: -- - git@github.com:owner/repo.git --``` -- --- `sync_interval_minutes` must be at least `5`; `local_path` must be an absolute path. Clones are stored below it as `host/namespace/repo`. --- Remote URLs must use SSH SCP form and are limited to GitHub, GitLab, or Gitee. --- `branches` are tried in order. The legacy `branch` field is supported, but do not set both. --- Analysis runs in an isolated worker; `analyze_timeout` defaults to, and cannot exceed, half of `sync_interval_minutes`. Timeout and `auto-sync stop` request safe cancellation; a worker in native work exits after reaching a JS-visible safe point. Until then, auto-sync reports `cancelling` or `stopping` and retains ownership so another auto-sync cannot take over, for up to 5 seconds — after that the parent stops waiting and leaves the worker to exit on its own rather than killing it mid-write. This behavior is the same on macOS and Windows. `overwrite_local_changes` defaults to `false`, so a dirty local clone is skipped rather than overwritten; setting it to `true` also deletes untracked files in the clone, while keeping ignored paths. --- Add `group_name` only after creating that group with `gitnexus group create `. Partial clone output is isolated and removed after 14 days. -- --See the [full auto-sync configuration and runtime reference](gitnexus/README.md#gitnexus-auto-sync) for concurrency, timeouts, failure thresholds, and runtime files. -- --
-- -
- Repository groups (multi-repo / monorepo service tracking) - -diff --git a/eslint-rules/require-safe-parse.mjs b/eslint-rules/require-safe-parse.mjs -index 4bad4280d..4ab9dbd8a 100644 ---- a/eslint-rules/require-safe-parse.mjs -+++ b/eslint-rules/require-safe-parse.mjs -@@ -19,14 +19,14 @@ - * - * False-positive suppression: - * - Skips calls whose receiver is a known non-tree-sitter library (`JSON`, -- * `URL`, `marked`, `Number`, `path`). -+ * `URL`, `marked`, `Number`). - * - Skips calls whose first argument is a string-literal (grammar-load smoke - * tests like `_testParser.parse('service X { rpc Y (R) returns (R); }')`). - * - Skips test files (`.test.ts`/`.test.tsx`/`.spec.ts`). - * - Skips the `safe-parse.ts` helper itself. - */ - --const SKIPPED_RECEIVERS = new Set(['JSON', 'URL', 'marked', 'Number', 'Math', 'path']); -+const SKIPPED_RECEIVERS = new Set(['JSON', 'URL', 'marked', 'Number', 'Math']); - - export default { - meta: { -@@ -74,7 +74,7 @@ export default { - // Receiver-text-shape skip: anything matching well-known JS APIs that - // happen to have a `.parse()` shape but aren't tree-sitter. - if ( -- /^(JSON|URL|marked|Number|Math|Date|path|globalThis\.JSON)\b/.test(receiverText) || -+ /^(JSON|URL|marked|Number|Math|Date|globalThis\.JSON)\b/.test(receiverText) || - /\bjson\.parse\b/i.test(receiverText) - ) { - return; -diff --git a/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md b/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md -index be02d92fd..170703439 100644 ---- a/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md -+++ b/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md -@@ -33,7 +33,7 @@ Run from the project root. This parses all source files, builds the knowledge gr - - For Spring runtime enrichment, pass a JSON bundle, one endpoint JSON file, or a directory containing endpoint files. Route evidence is authoritative only when `runtimeConfirmed === true`; `runtimeSource` records provenance and may also accompany `handler-conflict`. Env/configprops values are never persisted. - --Use `node .gitnexus/run.cjs analyze --watch` for a long-lived local Git repository. It performs an initial analysis, queues scanner-admitted file changes, and retries intact failed batches with bounded backoff. Watch refreshes update only the graph: they skip AGENTS.md / CLAUDE.md injection and standard skill installation, so run a one-shot `analyze` when those generated files need updating. Watch rejects one-shot or context-output flags including `--force`, embedding flags, `--skills`, `--default-branch`, `--skip-agents-md`, `--skip-skills`, `--no-stats`, `--self-commit`, `--index-only`, and `--skip-git`. It never pulls remotes. Scheduled remote clone/pull is a different command: `gitnexus auto-sync`. Bare `gitnexus watch` is reserved and does not start either job. Running MCP and `serve` processes periodically check for a published replacement and reopen it without a restart. MCP checks are throttled to once every five seconds, so a tool call before the next check can briefly use the previous index. -+Use `node .gitnexus/run.cjs analyze --watch` for a long-lived local Git repository. It performs an initial analysis, queues scanner-admitted file changes, and retries intact failed batches with bounded backoff. Watch refreshes update only the graph: they skip AGENTS.md / CLAUDE.md injection and standard skill installation, so run a one-shot `analyze` when those generated files need updating. Watch rejects one-shot or context-output flags including `--force`, embedding flags, `--skills`, `--default-branch`, `--skip-agents-md`, `--skip-skills`, `--no-stats`, `--self-commit`, `--index-only`, and `--skip-git`. It never pulls remotes. Running MCP and `serve` processes periodically check for a published replacement and reopen it without a restart. MCP checks are throttled to once every five seconds, so a tool call before the next check can briefly use the previous index. - - ### status — Check index freshness - -diff --git a/gitnexus/README.md b/gitnexus/README.md -index 9abc122c5..12bd96d20 100644 ---- a/gitnexus/README.md -+++ b/gitnexus/README.md -@@ -249,7 +249,6 @@ gitnexus analyze --verbose # Log skipped files when parsers are unavailabl - gitnexus analyze --max-file-size 1024 # Skip files larger than N KB (default: 512, cap: 32768) - gitnexus analyze --worker-timeout 60 # Increase worker idle timeout for slow parses - gitnexus analyze --wal-checkpoint-threshold 67108864 # 64 MiB. Control LadybugDB WAL auto-checkpoint threshold (default: 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB) --gitnexus auto-sync [init|start|restart|stop|status|reset] # Scheduled remote clone/pull + analyze from GITNEXUS_HOME/watch_config.yml - gitnexus mcp # Start MCP server (stdio) — serves all indexed repos - gitnexus serve # Start local HTTP server (multi-repo) for web UI - gitnexus index # Register an existing .gitnexus/ folder into the global registry -@@ -310,28 +309,6 @@ and `serve` processes periodically check for a newly published index and reopen - it without a restart. MCP checks are throttled to once every five seconds, so a - tool call before the next check can briefly use the previous index. - --### `gitnexus auto-sync` -- --`gitnexus auto-sync` is a different product from `gitnexus analyze --watch`. It is the explicit long-running auto-sync entrypoint that clones or pulls configured remotes. `gitnexus watch` is reserved and does not start either job: it prints this split. `GITNEXUS_HOME` defaults to `~/.gitnexus`; `gitnexus auto-sync init` creates its default `$GITNEXUS_HOME/watch_config.yml`. Bare `gitnexus auto-sync` is the same as `gitnexus auto-sync start`; `restart`, `stop`, `status`, and `reset` manage the same `GITNEXUS_HOME` instance. `reset` removes only the derived analysis state and commit snapshot; clones, indexes, and registry entries are untouched. `start` runs in the foreground, reads the configuration once at startup, runs once immediately, then repeats on `sync_interval_minutes`; restart it after changing the configuration. Watch runtime artifacts live under `$GITNEXUS_HOME/watch/`: `project_commit_info.txt` is the human-readable per-loop snapshot, `auto-sync-state.json` is the machine state used for commit skipping and analyze failure thresholds, `watch.mutex` prevents multiple auto-sync processes for one home, `watch.owner.json` records ownership metadata, `watch.pid` plus `watch.status.json` expose process state, `watch.stop..json` is a temporary owner-fenced stop request, and `quarantine/` stores partial clone output before entries are removed after 14 days, keeping at most the five newest entries per repository regardless of age. Mutexes with verified dead owners are reclaimed automatically after an abnormal exit. Invalid or legacy mutexes fail closed; confirm no auto-sync process is running before manually removing `watch.mutex` and stale `watch.pid` / `watch.owner.json`. -- --```yaml --sync_interval_minutes: 10 --max_concurrency: 1 --repo_git_timeout: 10s --analyze_timeout: 5m --analyze_failure_threshold: 3 --projects: -- - local_path: /abs/path/to/repos -- branches: [master, main] -- overwrite_local_changes: false -- remote_urls: -- - git@github.com:owner/repo.git -- - git@gitlab.com:group/repo.git -- - git@gitee.com:owner/repo.git --``` -- --`sync_interval_minutes` must be an integer of at least `5`. `local_path` must be an absolute path without traversal; each remote is cloned below it as `host/namespace/repo`, preventing same-basename repositories from colliding. `remote_urls` must use SSH SCP form for github.com, gitlab.com, or gitee.com. `repo_git_timeout` applies to each repo clone/pull and defaults to `10s`; a bare number such as `10` is interpreted as seconds, while `10000ms`, `10s`, and `1m` keep their explicit units. It must not exceed one hour or `sync_interval_minutes`, whichever is smaller — so a bare `600000` is rejected, because it means 600000 seconds rather than milliseconds. `analyze_timeout` applies to each isolated analysis worker, defaults to half of `sync_interval_minutes`, and cannot exceed that value; this keeps it within Node's timer range. Timeout and `auto-sync stop` request safe cancellation; a worker already in native work exits after it returns to a JS-visible safe point. While waiting, auto-sync reports `cancelling` or `stopping` and keeps its ownership files so another auto-sync cannot take over. The parent waits up to 5 seconds for the worker to exit; after that it stops waiting, releases its ownership files, and leaves the worker to finish and exit on its own rather than killing it mid-write. `auto-sync stop` uses this same control path on macOS and Windows. `overwrite_local_changes` defaults to `false`; a dirty local clone is skipped with an error log, while `true` allows branch fallback to replace local changes and additionally discards untracked files and directories in the clone after checkout — ignored paths, including GitNexus's own `.gitnexus/` storage, are preserved. `max_concurrency` defaults to `1` and is capped at runtime by `floor(availableMemoryGB / 2)` with a minimum of `1`; the effective value is printed at the start of each loop. Each analysis worker's heap cap is the machine-wide cap divided by the number of repositories analyzed in parallel, so concurrent workers share one memory budget instead of each claiming the whole machine. `analyze_failure_threshold` defaults to `3`, must be at least `2`, and pauses repeated failures only for the same repo branch and commit; a new commit or `gitnexus auto-sync reset` clears the block and allows analysis again. Repositories are registered and added to groups by their full remote identity (`host/namespace/repo`), so repositories with the same basename remain distinct. Use `branches` to try branches in order; legacy `branch` remains supported, but the two fields cannot be set together. If all branches are unavailable or time out, watch logs an error, records the repo status, and skips that repo for the loop. Leave `group_name` empty or omit it to skip group add/sync for that project; otherwise create the group first with `gitnexus group create `. `$GITNEXUS_HOME/watch/project_commit_info.txt` is for inspection only; GitNexus stores machine state separately in `$GITNEXUS_HOME/watch/auto-sync-state.json`. -- - GraphQL contract matching is opt-in in the group's `group.yaml`: - - ```yaml -diff --git a/gitnexus/skills/gitnexus-cli.md b/gitnexus/skills/gitnexus-cli.md -index be02d92fd..170703439 100644 ---- a/gitnexus/skills/gitnexus-cli.md -+++ b/gitnexus/skills/gitnexus-cli.md -@@ -33,7 +33,7 @@ Run from the project root. This parses all source files, builds the knowledge gr - - For Spring runtime enrichment, pass a JSON bundle, one endpoint JSON file, or a directory containing endpoint files. Route evidence is authoritative only when `runtimeConfirmed === true`; `runtimeSource` records provenance and may also accompany `handler-conflict`. Env/configprops values are never persisted. - --Use `node .gitnexus/run.cjs analyze --watch` for a long-lived local Git repository. It performs an initial analysis, queues scanner-admitted file changes, and retries intact failed batches with bounded backoff. Watch refreshes update only the graph: they skip AGENTS.md / CLAUDE.md injection and standard skill installation, so run a one-shot `analyze` when those generated files need updating. Watch rejects one-shot or context-output flags including `--force`, embedding flags, `--skills`, `--default-branch`, `--skip-agents-md`, `--skip-skills`, `--no-stats`, `--self-commit`, `--index-only`, and `--skip-git`. It never pulls remotes. Scheduled remote clone/pull is a different command: `gitnexus auto-sync`. Bare `gitnexus watch` is reserved and does not start either job. Running MCP and `serve` processes periodically check for a published replacement and reopen it without a restart. MCP checks are throttled to once every five seconds, so a tool call before the next check can briefly use the previous index. -+Use `node .gitnexus/run.cjs analyze --watch` for a long-lived local Git repository. It performs an initial analysis, queues scanner-admitted file changes, and retries intact failed batches with bounded backoff. Watch refreshes update only the graph: they skip AGENTS.md / CLAUDE.md injection and standard skill installation, so run a one-shot `analyze` when those generated files need updating. Watch rejects one-shot or context-output flags including `--force`, embedding flags, `--skills`, `--default-branch`, `--skip-agents-md`, `--skip-skills`, `--no-stats`, `--self-commit`, `--index-only`, and `--skip-git`. It never pulls remotes. Running MCP and `serve` processes periodically check for a published replacement and reopen it without a restart. MCP checks are throttled to once every five seconds, so a tool call before the next check can briefly use the previous index. - - ### status — Check index freshness - -diff --git a/gitnexus/src/cli/analyze-watch.ts b/gitnexus/src/cli/analyze-watch.ts -deleted file mode 100644 -index 2e4744bc5..000000000 ---- a/gitnexus/src/cli/analyze-watch.ts -+++ /dev/null -@@ -1,504 +0,0 @@ --/** Local incremental watch (`gitnexus analyze --watch`). Remote auto-sync lives in `auto-sync.ts`. */ --import path from 'node:path'; --import fs from 'node:fs/promises'; --import { watch, type FSWatcher } from 'chokidar'; --import { createWatchIgnorePredicate } from '../config/ignore-service.js'; --import { -- analyzeFailureMayHaveMutatedLiveIndex, -- runFullAnalysis, -- type AnalyzeOptions as CoreAnalyzeOptions, -- type AnalyzeResult, --} from '../core/run-analyze.js'; --import { getGitRoot, hasGitDir } from '../storage/git.js'; --import type { AnalyzerRunnerIdentity } from '../storage/repo-manager.js'; --import { GITNEXUS_DIR } from '../storage/repo-meta.js'; --import { -- loadAnalyzeConfigStrict, -- mergeAnalyzeOptions, -- validateBranchName, --} from './analyze-config.js'; --import type { AnalyzeOptions } from './analyze-options.js'; --import { ensureHeap } from './analyze.js'; --import { cliError, cliInfo, cliWarn } from './cli-message.js'; --import { -- WATCH_FULL_REFRESH_PATH, -- WatchRefreshQueue, -- type WatchRefreshError, --} from './watch-queue.js'; -- --const DEFAULT_DEBOUNCE_MS = 300; --const MAX_TIMER_DELAY_MS = 2_147_483_647; --const MAX_FILE_SIZE_KB = 32 * 1024; --const TRANSIENT_WATCH_ERROR_CODES = new Set(['EACCES', 'ENOENT', 'ENOTDIR', 'EPERM']); -- --export type WatchCliOptions = AnalyzeOptions; -- --function posixWatchPath(filePath: string): string { -- return filePath.replace(/\\/g, '/').replace(/^\.\/+/, ''); --} -- --export function isRelevantWatchPath(filePath: string): boolean { -- const normalized = posixWatchPath(filePath); -- return ( -- normalized.length > 0 && -- normalized !== '.' && -- !normalized.startsWith('../') && -- !path.posix.isAbsolute(normalized) && -- !path.win32.isAbsolute(filePath) -- ); --} -- --function isIgnoreControlPath(filePath: string): boolean { -- const normalized = posixWatchPath(filePath); -- return normalized === '.gitignore' || normalized === '.gitnexusignore'; --} -- --function isConfigControlPath(filePath: string): boolean { -- return posixWatchPath(filePath) === '.gitnexusrc'; --} -- --function isAnalyzerOwnedWatchPath(filePath: string): boolean { -- const normalized = posixWatchPath(filePath).replace(/\/+$/, ''); -- return normalized === GITNEXUS_DIR || normalized.startsWith(`${GITNEXUS_DIR}/`); --} -- --function repoRelativeWatchPath(repoPath: string, candidate: string): string | null { -- const relative = path.relative(repoPath, candidate).replace(/\\/g, '/'); -- if (!relative || relative.startsWith('../') || path.isAbsolute(relative)) return null; -- return relative; --} -- --export interface WatchEnvironmentBaseline { -- readonly maxFileSize: string | undefined; -- readonly workerTimeout: string | undefined; -- readonly verbose: string | undefined; --} -- --function setEnvironment(name: string, value: string | undefined): void { -- if (value === undefined) delete process.env[name]; -- else process.env[name] = value; --} -- --function positiveInteger( -- value: string | undefined, -- flag: string, -- maximum?: number, --): number | undefined { -- if (value === undefined) return undefined; -- const parsed = Number(value); -- if (!Number.isInteger(parsed) || parsed < 1) -- throw new Error(`${flag} must be a positive integer`); -- if (maximum !== undefined && parsed > maximum) { -- throw new Error(`${flag} must not exceed ${maximum}`); -- } -- return parsed; --} -- --export async function resolveWatchOptions( -- repoPath: string, -- cli: WatchCliOptions, -- baseline: WatchEnvironmentBaseline, -- reportIgnoredConfig: (names: readonly string[]) => void = () => {}, --): Promise { -- const config = (await loadAnalyzeConfigStrict(repoPath)) ?? {}; -- const merged = mergeAnalyzeOptions(cli, config); -- const unsupported = [ -- ['--force', cli.force], -- ['--repair-fts', cli.repairFts], -- ['--embeddings', cli.embeddings], -- ['--drop-embeddings', cli.dropEmbeddings], -- ['--skills', cli.skills], -- ['--default-branch', cli.defaultBranch], -- ['--skip-agents-md', cli.skipAgentsMd], -- ['--skip-skills', cli.skipSkills], -- ['--no-stats', cli.stats === false], -- ['--self-commit', cli.selfCommit], -- ['--index-only', cli.indexOnly], -- ['--skip-git', cli.skipGit], -- ['--spring-actuator', cli.springActuator], -- ['walCheckpointThreshold', cli.walCheckpointThreshold], -- ['embeddingThreads', cli.embeddingThreads], -- ['embeddingBatchSize', cli.embeddingBatchSize], -- ['embeddingSubBatchSize', cli.embeddingSubBatchSize], -- ['embeddingDevice', cli.embeddingDevice], -- ['embeddingBaseUrl', cli.embeddingBaseUrl], -- ['embeddingModel', cli.embeddingModel], -- ['--embedding-auth-token', cli.embeddingAuthToken], -- ['--embedding-dims', cli.embeddingDims], -- ].filter(([, value]) => value !== undefined && value !== false); -- if (unsupported.length > 0) { -- throw new Error( -- `analyze --watch does not support ${unsupported.map(([name]) => name).join(', ')}`, -- ); -- } -- reportIgnoredConfig( -- [ -- ['embeddings', config.embeddings], -- ['dropEmbeddings', config.dropEmbeddings], -- ['defaultBranch', config.defaultBranch], -- ['skipAgentsMd', config.skipAgentsMd !== undefined], -- ['skipSkills', config.skipSkills !== undefined], -- ['stats', config.stats !== undefined], -- ['springActuator', config.springActuator], -- ['walCheckpointThreshold', config.walCheckpointThreshold], -- ['embeddingThreads', config.embeddingThreads], -- ['embeddingBatchSize', config.embeddingBatchSize], -- ['embeddingSubBatchSize', config.embeddingSubBatchSize], -- ['embeddingDevice', config.embeddingDevice], -- ['embeddingBaseUrl', config.embeddingBaseUrl], -- ['embeddingModel', config.embeddingModel], -- ] -- .filter(([, value]) => value !== undefined && value !== false) -- .map(([name]) => String(name)), -- ); -- const branch = -- merged.branch === undefined ? undefined : validateBranchName(merged.branch, '--branch'); -- const workerPoolSize = positiveInteger(merged.workers, '--workers'); -- const workerTimeoutSeconds = positiveInteger(merged.workerTimeout, 'workerTimeout'); -- const maxFileSize = positiveInteger(merged.maxFileSize, 'maxFileSize', MAX_FILE_SIZE_KB); -- -- setEnvironment( -- 'GITNEXUS_MAX_FILE_SIZE', -- maxFileSize === undefined ? baseline.maxFileSize : String(maxFileSize), -- ); -- if (workerTimeoutSeconds !== undefined) { -- process.env.GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS = String(workerTimeoutSeconds * 1000); -- } else { -- setEnvironment('GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS', baseline.workerTimeout); -- } -- setEnvironment('GITNEXUS_VERBOSE', merged.verbose ? '1' : baseline.verbose); -- -- return { -- pdg: merged.pdg, -- branch, -- registryName: merged.name, -- allowDuplicateName: merged.allowDuplicateName, -- workerPoolSize, -- fetchWrappers: merged.fetchWrappers, -- skipAgentsMd: true, -- skipSkills: true, -- noStats: true, -- atomicIncremental: process.platform !== 'win32', -- }; --} -- --function refreshSummary( -- result: AnalyzeResult, -- observedPaths: readonly string[], -- durationMs: number, -- lastSuccessfulRefreshAt: string, --): string { -- const measured = result.incrementalStats; -- const changed = measured?.changedFiles ?? (result.alreadyUpToDate ? 0 : observedPaths.length); -- const reparsed = -- measured?.reparsedFiles ?? -- (typeof result.pipelineResult?.reparsedFileCount === 'number' -- ? result.pipelineResult.reparsedFileCount -- : 0); -- const dependents = measured?.affectedDependents ?? 0; -- const mode = measured?.writeMode ?? (result.alreadyUpToDate ? 'no-op' : 'full'); -- return ( -- `Refresh complete: ${changed} changed, ${reparsed} re-parsed, ` + -- `${dependents} affected dependent(s), ${durationMs}ms, ${mode}; ` + -- `last success ${lastSuccessfulRefreshAt}` -- ); --} -- --async function waitUntilReady(watcher: FSWatcher): Promise { -- await new Promise((resolve, reject) => { -- const ready = () => { -- watcher.off('error', failed); -- resolve(); -- }; -- const failed = (error: unknown) => { -- watcher.off('ready', ready); -- reject(error); -- }; -- watcher.once('ready', ready); -- watcher.once('error', failed); -- }); --} -- --export interface WatchFileLoop { -- readonly waitForIdle: () => Promise; -- readonly close: () => Promise; --} -- --class WatchControlReloadError extends Error { -- constructor(cause: unknown) { -- super(cause instanceof Error ? cause.message : String(cause), { cause }); -- this.name = 'WatchControlReloadError'; -- } --} -- --export function shouldStopAfterWatchRefreshFailure( -- error: unknown, -- paths: readonly string[], --): boolean { -- return ( -- paths.length > 0 && -- !(error instanceof WatchControlReloadError) && -- analyzeFailureMayHaveMutatedLiveIndex(error) -- ); --} -- --/** Start the real filesystem watcher with bounded, serialized refreshes. */ --export async function startWatchFileLoop( -- repoPath: string, -- debounceMs: number, -- refresh: (paths: readonly string[]) => Promise, -- onError: WatchRefreshError, -- onWatcherError: (error: unknown) => void = (error) => onError(error, []), --): Promise { -- let ignorePath = await createWatchIgnorePredicate(repoPath); -- let ignoreControlValid = true; -- const queue = new WatchRefreshQueue( -- async (paths) => { -- if (paths.some(isIgnoreControlPath) || !ignoreControlValid) { -- const retryingInvalidControls = !ignoreControlValid; -- try { -- ignorePath = await createWatchIgnorePredicate(repoPath); -- ignoreControlValid = true; -- watcher.add(repoPath); -- } catch (error) { -- ignoreControlValid = false; -- throw new WatchControlReloadError( -- retryingInvalidControls -- ? new Error( -- 'Ignore controls remain invalid; fix them before indexing more changes.', -- { -- cause: error, -- }, -- ) -- : error, -- ); -- } -- } -- await refresh(paths); -- }, -- onError, -- debounceMs, -- { -- maxWaitMs: Math.max(2_000, debounceMs * 10), -- maxPendingPaths: 1_000, -- holdEventsUntilInitialRefresh: true, -- isPriorityPath: (filePath) => isIgnoreControlPath(filePath) || isConfigControlPath(filePath), -- }, -- ); -- -- const watcher: FSWatcher = watch(repoPath, { -- ignoreInitial: true, -- atomic: true, -- followSymlinks: false, -- awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 20 }, -- ignored: (candidate, stats) => { -- const relative = repoRelativeWatchPath(repoPath, candidate); -- if (relative !== null && isAnalyzerOwnedWatchPath(relative)) return true; -- if (relative !== null && (isIgnoreControlPath(relative) || isConfigControlPath(relative))) { -- return false; -- } -- return ignorePath(candidate, stats?.isDirectory() ?? false); -- }, -- }); -- watcher.on('all', (event, changedPath) => { -- if (event !== 'add' && event !== 'change' && event !== 'unlink') return; -- const relative = repoRelativeWatchPath(repoPath, changedPath); -- if (relative && isRelevantWatchPath(relative) && !isAnalyzerOwnedWatchPath(relative)) { -- queue.enqueue(relative); -- } -- }); -- watcher.on('error', (error) => { -- // Chokidar can surface a transient EPERM on Windows while an ignored -- // analyzer-owned path is replaced. Re-arm the root and force one bounded -- // catch-up refresh so a missed event cannot leave the graph stale. Other -- // watcher errors may mean coverage was lost and remain fatal. -- if (TRANSIENT_WATCH_ERROR_CODES.has((error as NodeJS.ErrnoException).code ?? '')) { -- watcher.add(repoPath); -- queue.enqueue(WATCH_FULL_REFRESH_PATH); -- return; -- } -- onWatcherError(error); -- }); -- -- try { -- await waitUntilReady(watcher); -- await queue.runInitial(); -- } catch (error) { -- await watcher.close(); -- await queue.close(); -- throw error; -- } -- -- return { -- waitForIdle: () => queue.waitForIdle(), -- close: async () => { -- await watcher.close(); -- await queue.close(); -- }, -- }; --} -- --export async function watchCommandWithRunnerIdentity( -- runnerIdentityAtBootstrap: AnalyzerRunnerIdentity, -- inputPath?: string, -- cliOptions: WatchCliOptions = {}, --): Promise { -- if (await ensureHeap({ cleanForwardedTermination: true })) return; -- -- const requestedRepoPath = inputPath ? path.resolve(inputPath) : getGitRoot(process.cwd()); -- if (requestedRepoPath === null || !hasGitDir(requestedRepoPath)) { -- cliError(' gitnexus analyze --watch requires a Git repository.'); -- process.exitCode = 1; -- return; -- } -- const repoPath = await fs.realpath(requestedRepoPath); -- const baselineEnvironment: WatchEnvironmentBaseline = { -- maxFileSize: process.env.GITNEXUS_MAX_FILE_SIZE, -- workerTimeout: process.env.GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS, -- verbose: process.env.GITNEXUS_VERBOSE, -- }; -- try { -- let ignoredConfigSignature: string | undefined; -- const reportIgnoredConfig = (names: readonly string[]) => { -- const signature = [...names].sort().join(','); -- if (signature === ignoredConfigSignature) return; -- ignoredConfigSignature = signature; -- if (names.length > 0) { -- cliWarn(`Watch mode ignores unsupported .gitnexusrc settings: ${names.join(', ')}.`); -- } -- }; -- let debounceMs: number; -- let analyzeOptions: CoreAnalyzeOptions; -- try { -- debounceMs = -- positiveInteger( -- cliOptions.debounce ?? String(DEFAULT_DEBOUNCE_MS), -- '--debounce', -- MAX_TIMER_DELAY_MS, -- ) ?? DEFAULT_DEBOUNCE_MS; -- analyzeOptions = await resolveWatchOptions( -- repoPath, -- cliOptions, -- baselineEnvironment, -- reportIgnoredConfig, -- ); -- } catch (error) { -- cliError(` ${error instanceof Error ? error.message : String(error)}`); -- process.exitCode = 1; -- return; -- } -- -- let stopWatching!: () => void; -- const stopped = new Promise((resolve) => { -- stopWatching = resolve; -- }); -- const stop = () => stopWatching(); -- process.once('SIGINT', stop); -- process.once('SIGTERM', stop); -- try { -- let loop: WatchFileLoop; -- let fatalRefreshError: unknown; -- let configControlValid = true; -- let lastSuccessfulRefreshAt: string | undefined; -- try { -- loop = await startWatchFileLoop( -- repoPath, -- debounceMs, -- async (paths) => { -- if (paths.some(isConfigControlPath) || !configControlValid) { -- const retryingInvalidConfig = !configControlValid; -- try { -- analyzeOptions = await resolveWatchOptions( -- repoPath, -- cliOptions, -- baselineEnvironment, -- reportIgnoredConfig, -- ); -- configControlValid = true; -- } catch (error) { -- configControlValid = false; -- throw new WatchControlReloadError( -- retryingInvalidConfig -- ? new Error( -- 'Configuration remains invalid; fix it before indexing more changes.', -- { -- cause: error, -- }, -- ) -- : error, -- ); -- } -- } -- const startedAt = Date.now(); -- const result = await runFullAnalysis( -- repoPath, -- analyzeOptions, -- { -- onProgress: () => {}, -- onLog: -- process.env.GITNEXUS_VERBOSE === '1' -- ? (message) => cliInfo(` ${message}`) -- : undefined, -- }, -- runnerIdentityAtBootstrap, -- ); -- lastSuccessfulRefreshAt = new Date().toISOString(); -- if (paths.length === 0) { -- cliInfo( -- result.alreadyUpToDate -- ? `Watching ${repoPath}; index is up to date.` -- : `Watching ${repoPath}; initial index ready in ${Date.now() - startedAt}ms.`, -- ); -- } else { -- cliInfo( -- refreshSummary(result, paths, Date.now() - startedAt, lastSuccessfulRefreshAt), -- ); -- } -- }, -- (error, paths) => { -- const detail = paths.length > 0 ? ` (${paths.length} queued path(s))` : ''; -- if (shouldStopAfterWatchRefreshFailure(error, paths)) { -- fatalRefreshError = error; -- cliError( -- `Refresh failed${detail}: ${error instanceof Error ? error.message : String(error)}. ` + -- 'Watch mode is stopping because the live index may have been updated in place.', -- ); -- stopWatching(); -- return; -- } -- const lastSuccess = lastSuccessfulRefreshAt ?? 'none yet'; -- cliWarn( -- `Refresh failed${detail}: ${error instanceof Error ? error.message : String(error)}. ` + -- `Retry scheduled; last success ${lastSuccess}.`, -- ); -- }, -- (error) => { -- fatalRefreshError = error; -- cliError( -- `Watcher failed: ${error instanceof Error ? error.message : String(error)}. ` + -- 'Watch mode is stopping.', -- ); -- stopWatching(); -- }, -- ); -- } catch (error) { -- cliError( -- ` Unable to start watcher: ${error instanceof Error ? error.message : String(error)}`, -- ); -- process.exitCode = 1; -- return; -- } -- -- await stopped; -- await loop.close(); -- if (fatalRefreshError !== undefined) process.exitCode = 1; -- } finally { -- process.removeListener('SIGINT', stop); -- process.removeListener('SIGTERM', stop); -- } -- } finally { -- setEnvironment('GITNEXUS_MAX_FILE_SIZE', baselineEnvironment.maxFileSize); -- setEnvironment('GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS', baselineEnvironment.workerTimeout); -- setEnvironment('GITNEXUS_VERBOSE', baselineEnvironment.verbose); -- } --} -diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts -index beb553e01..1fa987320 100644 ---- a/gitnexus/src/cli/analyze.ts -+++ b/gitnexus/src/cli/analyze.ts -@@ -776,7 +776,7 @@ export async function analyzeOrWatchCommandWithRunnerIdentity( - options: AnalyzeOptions = {}, - ): Promise { - if (options.watch) { -- const { watchCommandWithRunnerIdentity } = await import('./analyze-watch.js'); -+ const { watchCommandWithRunnerIdentity } = await import('./watch.js'); - await watchCommandWithRunnerIdentity(runnerIdentityAtBootstrap, inputPath, options); - return; - } -diff --git a/gitnexus/src/cli/auto-sync.ts b/gitnexus/src/cli/auto-sync.ts -deleted file mode 100644 -index 880deb5d5..000000000 ---- a/gitnexus/src/cli/auto-sync.ts -+++ /dev/null -@@ -1,125 +0,0 @@ --/** Remote auto-sync CLI (`gitnexus auto-sync`). Local incremental watch lives in `analyze-watch.ts`. */ --import fs from 'node:fs/promises'; --import path from 'node:path'; --import { -- getAutoSyncConfigPath, -- getAutoSyncMutexPath, -- readAutoSyncWatchStatus, -- resetAutoSyncState, -- startAutoSyncWatch, -- stopAutoSyncWatch, -- type WatchStatusRecord, --} from '../core/auto-sync/index.js'; -- --export async function autoSyncCommand(action = 'start'): Promise { -- if (action === 'init') { -- await initWatchConfig(); -- return; -- } -- if (action === 'reset') { -- if (!(await resetAutoSyncState())) { -- process.stderr.write( -- `[auto-sync] Cannot reset analysis state while the watch mutex is held. Confirm no watch process is running, then remove ${getAutoSyncMutexPath()}.\n`, -- ); -- process.exitCode = 1; -- return; -- } -- process.stdout.write('[auto-sync] Reset analysis state.\n'); -- return; -- } -- if (action === 'status') { -- printStatus(await readAutoSyncWatchStatus()); -- return; -- } -- if (action === 'stop') { -- if ((await stopAutoSyncWatch()) !== 'stopped') process.exitCode = 1; -- return; -- } -- if (action === 'restart') { -- const result = await stopAutoSyncWatch(); -- if (result === 'refused' || result === 'timeout') { -- process.exitCode = 1; -- return; -- } -- await startWatchProcess(); -- return; -- } -- if (action !== 'start') { -- process.stderr.write(`[auto-sync] Unknown auto-sync action: ${action}\n`); -- process.exitCode = 1; -- return; -- } -- await startWatchProcess(); --} -- --async function startWatchProcess(): Promise { -- const handle = await startAutoSyncWatch(); -- if (!handle) { -- process.exitCode = 1; -- return; -- } -- -- const stop = () => { -- void handle.stop().then( -- () => { -- process.stderr.write('[auto-sync] Watch stopped.\n'); -- process.exit(0); -- }, -- (error: unknown) => { -- const message = error instanceof Error ? error.message : String(error); -- process.stderr.write(`[auto-sync] Failed to stop watch: ${message}\n`); -- process.exit(1); -- }, -- ); -- }; -- process.once('SIGINT', stop); -- process.once('SIGTERM', stop); --} -- --function printStatus(status: WatchStatusRecord): void { -- const parts = [`state=${status.state}`]; -- if (status.pid) parts.push(`pid=${status.pid}`); -- if (status.configPath) parts.push(`config=${status.configPath}`); -- if (status.message) parts.push(`message=${status.message}`); -- parts.push(`updated_at=${status.updatedAt}`); -- process.stdout.write(`${parts.join(' ')}\n`); --} -- --async function initWatchConfig(): Promise { -- const configPath = getAutoSyncConfigPath(); -- try { -- await fs.mkdir(path.dirname(configPath), { recursive: true }); -- await fs.writeFile( -- configPath, -- defaultSyncConfig(path.resolve(path.dirname(configPath), 'repos')), -- { -- flag: 'wx', -- }, -- ); -- } catch (err: unknown) { -- if ((err as NodeJS.ErrnoException).code === 'EEXIST') { -- process.stderr.write(`[auto-sync] Config already exists: ${configPath}\n`); -- process.exitCode = 1; -- return; -- } -- throw err; -- } -- process.stdout.write(`[auto-sync] Created ${configPath}\n`); --} -- --function defaultSyncConfig(localPath: string): string { -- return [ -- 'sync_interval_minutes: 10', -- 'max_concurrency: 1', -- 'repo_git_timeout: 10s', -- 'analyze_timeout: 5m', -- 'analyze_failure_threshold: 3', -- 'projects:', -- ` - local_path: ${localPath}`, -- ' branches: [master, main]', -- ' overwrite_local_changes: false', -- ' remote_urls:', -- ' - git@github.com:owner/repo.git', -- '', -- ].join('\n'); --} -diff --git a/gitnexus/src/cli/help-i18n.ts b/gitnexus/src/cli/help-i18n.ts -index 283e99832..cd4724d70 100644 ---- a/gitnexus/src/cli/help-i18n.ts -+++ b/gitnexus/src/cli/help-i18n.ts -@@ -13,8 +13,6 @@ const COMMAND_DESCRIPTION_KEYS = { - '': 'help.description.root', - setup: 'help.command.setup.description', - uninstall: 'help.command.uninstall.description', -- watch: 'help.command.watch.description', -- 'auto-sync': 'help.command.autoSync.description', - analyze: 'help.command.analyze.description', - index: 'help.command.index.description', - serve: 'help.command.serve.description', -diff --git a/gitnexus/src/cli/i18n/en.ts b/gitnexus/src/cli/i18n/en.ts -index 58904c48f..c54f5707b 100644 ---- a/gitnexus/src/cli/i18n/en.ts -+++ b/gitnexus/src/cli/i18n/en.ts -@@ -145,16 +145,6 @@ export const en = { - 'One-time setup: configure MCP for Cursor, Claude Code, Antigravity, OpenCode, CodeBuddy, Qoder, Codex', - 'help.command.uninstall.description': - 'Reverse `setup`: remove GitNexus MCP entries, skills, and hooks from all detected editors', -- 'help.command.autoSync.description': -- 'Control scheduled repository clone/pull and analysis from GITNEXUS_HOME/watch_config.yml', -- 'help.autoSync.details': -- '\nActions: init, start (default), restart, stop, status, reset\nConfiguration: GITNEXUS_HOME/watch_config.yml\nRuntime files: GITNEXUS_HOME/watch/watch.pid, watch.mutex, watch.owner.json, watch.status.json, auto-sync-state.json\nRecovery: mutexes with verified dead owners are reclaimed automatically; invalid or legacy mutexes fail closed and require manual removal after confirming no watch process is running.\nWrites: GITNEXUS_HOME/watch/project_commit_info.txt\nRemote URLs: only SSH URLs on github.com, gitlab.com, and gitee.com are allowed.\nRuns once immediately, then repeats on sync_interval_minutes.', -- 'help.command.watch.description': -- 'Ambiguous: use `analyze --watch` for local files, or `auto-sync` for scheduled remotes', -- 'help.watch.details': -- '\n`gitnexus watch` does not start a watcher.\n Local working-tree incremental index: gitnexus analyze --watch\n Scheduled remote clone/pull + analyze: gitnexus auto-sync start\n', -- 'error.watch.ambiguous': -- '`gitnexus watch` is ambiguous.\n Local working-tree incremental index: gitnexus analyze --watch\n Scheduled remote clone/pull + analyze: gitnexus auto-sync start\n', - 'help.command.analyze.description': 'Index a repository (full analysis)', - 'help.command.index.description': - 'Register an existing .gitnexus/ folder into the global registry (no re-analysis needed)', -diff --git a/gitnexus/src/cli/i18n/zh-CN.ts b/gitnexus/src/cli/i18n/zh-CN.ts -index de9249cc4..a8001af8a 100644 ---- a/gitnexus/src/cli/i18n/zh-CN.ts -+++ b/gitnexus/src/cli/i18n/zh-CN.ts -@@ -144,16 +144,6 @@ export const zhCN = { - '一次性设置:为 Cursor、Claude Code、Antigravity、OpenCode、CodeBuddy、Qoder、Codex 配置 MCP', - 'help.command.uninstall.description': - '撤销 `setup`:从所有检测到的编辑器中移除 GitNexus 的 MCP 配置、技能和钩子', -- 'help.command.autoSync.description': -- '控制基于 GITNEXUS_HOME/watch_config.yml 的定时 clone/pull 和分析', -- 'help.autoSync.details': -- '\n操作:init、start(默认)、restart、stop、status、reset\n配置:GITNEXUS_HOME/watch_config.yml\n运行时文件:GITNEXUS_HOME/watch/watch.pid、watch.mutex、watch.owner.json、watch.status.json、auto-sync-state.json\n恢复:已验证 owner 退出的 mutex 会自动回收;无效或旧版 mutex 会安全拒绝,确认没有 watch 进程运行后再手动删除。\n写入:GITNEXUS_HOME/watch/project_commit_info.txt\n远程地址:仅允许 github.com、gitlab.com 和 gitee.com 上的 SSH 地址。\n启动后立即运行一次,之后按 sync_interval_minutes 重复。', -- 'help.command.watch.description': -- '含义不明确:本地文件请用 `analyze --watch`,定时远程同步请用 `auto-sync`', -- 'help.watch.details': -- '\n`gitnexus watch` 不会启动监视器。\n 本地工作区增量索引:gitnexus analyze --watch\n 定时远程 clone/pull 并分析:gitnexus auto-sync start\n', -- 'error.watch.ambiguous': -- '`gitnexus watch` 含义不明确。\n 本地工作区增量索引:gitnexus analyze --watch\n 定时远程 clone/pull 并分析:gitnexus auto-sync start\n', - 'help.command.analyze.description': '索引仓库(完整分析)', - 'help.command.index.description': '将现有 .gitnexus/ 文件夹注册到全局注册表(无需重新分析)', - 'help.command.serve.description': '启动供 Web UI 连接的本地 HTTP 服务器', -diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts -index d48bc65c9..e53312d62 100644 ---- a/gitnexus/src/cli/index.ts -+++ b/gitnexus/src/cli/index.ts -@@ -45,22 +45,6 @@ program - .option('-f, --force', 'Apply the changes (default is a dry-run preview)') - .action(createLazyAction(() => import('./uninstall.js'), 'uninstallCommand')); - --program -- .command('auto-sync [action]') -- .description( -- 'Control scheduled repository clone/pull and analysis from GITNEXUS_HOME/watch_config.yml', -- ) -- .addHelpText('after', () => t('help.autoSync.details')) -- .action(createLazyAction(() => import('./auto-sync.js'), 'autoSyncCommand')); -- --program -- .command('watch [action]') -- .description( -- 'Ambiguous: use `analyze --watch` for local files, or `auto-sync` for scheduled remotes', -- ) -- .addHelpText('after', () => t('help.watch.details')) -- .action(createLazyAction(() => import('./watch.js'), 'watchAmbiguousCommand')); -- - // Baseline of GITNEXUS_EMBEDDING_DIMS captured by the analyze preAction hook - // before it overwrites the var, so the postAction hook can restore it. The - // analyzeCommand env snapshot is taken AFTER this hook runs, so it cannot undo -diff --git a/gitnexus/src/cli/watch.ts b/gitnexus/src/cli/watch.ts -index 2a779b51a..13212a56c 100644 ---- a/gitnexus/src/cli/watch.ts -+++ b/gitnexus/src/cli/watch.ts -@@ -1,7 +1,503 @@ --/** Reserved CLI verb: never starts either watch product. */ --import { t } from './i18n/index.js'; -+import path from 'node:path'; -+import fs from 'node:fs/promises'; -+import { watch, type FSWatcher } from 'chokidar'; -+import { createWatchIgnorePredicate } from '../config/ignore-service.js'; -+import { -+ analyzeFailureMayHaveMutatedLiveIndex, -+ runFullAnalysis, -+ type AnalyzeOptions as CoreAnalyzeOptions, -+ type AnalyzeResult, -+} from '../core/run-analyze.js'; -+import { getGitRoot, hasGitDir } from '../storage/git.js'; -+import type { AnalyzerRunnerIdentity } from '../storage/repo-manager.js'; -+import { GITNEXUS_DIR } from '../storage/repo-meta.js'; -+import { -+ loadAnalyzeConfigStrict, -+ mergeAnalyzeOptions, -+ validateBranchName, -+} from './analyze-config.js'; -+import type { AnalyzeOptions } from './analyze-options.js'; -+import { ensureHeap } from './analyze.js'; -+import { cliError, cliInfo, cliWarn } from './cli-message.js'; -+import { -+ WATCH_FULL_REFRESH_PATH, -+ WatchRefreshQueue, -+ type WatchRefreshError, -+} from './watch-queue.js'; - --export async function watchAmbiguousCommand(_action?: string): Promise { -- process.stderr.write(t('error.watch.ambiguous')); -- process.exitCode = 1; -+const DEFAULT_DEBOUNCE_MS = 300; -+const MAX_TIMER_DELAY_MS = 2_147_483_647; -+const MAX_FILE_SIZE_KB = 32 * 1024; -+const TRANSIENT_WATCH_ERROR_CODES = new Set(['EACCES', 'ENOENT', 'ENOTDIR', 'EPERM']); -+ -+export type WatchCliOptions = AnalyzeOptions; -+ -+function posixWatchPath(filePath: string): string { -+ return filePath.replace(/\\/g, '/').replace(/^\.\/+/, ''); -+} -+ -+export function isRelevantWatchPath(filePath: string): boolean { -+ const normalized = posixWatchPath(filePath); -+ return ( -+ normalized.length > 0 && -+ normalized !== '.' && -+ !normalized.startsWith('../') && -+ !path.posix.isAbsolute(normalized) && -+ !path.win32.isAbsolute(filePath) -+ ); -+} -+ -+function isIgnoreControlPath(filePath: string): boolean { -+ const normalized = posixWatchPath(filePath); -+ return normalized === '.gitignore' || normalized === '.gitnexusignore'; -+} -+ -+function isConfigControlPath(filePath: string): boolean { -+ return posixWatchPath(filePath) === '.gitnexusrc'; -+} -+ -+function isAnalyzerOwnedWatchPath(filePath: string): boolean { -+ const normalized = posixWatchPath(filePath).replace(/\/+$/, ''); -+ return normalized === GITNEXUS_DIR || normalized.startsWith(`${GITNEXUS_DIR}/`); -+} -+ -+function repoRelativeWatchPath(repoPath: string, candidate: string): string | null { -+ const relative = path.relative(repoPath, candidate).replace(/\\/g, '/'); -+ if (!relative || relative.startsWith('../') || path.isAbsolute(relative)) return null; -+ return relative; -+} -+ -+export interface WatchEnvironmentBaseline { -+ readonly maxFileSize: string | undefined; -+ readonly workerTimeout: string | undefined; -+ readonly verbose: string | undefined; -+} -+ -+function setEnvironment(name: string, value: string | undefined): void { -+ if (value === undefined) delete process.env[name]; -+ else process.env[name] = value; -+} -+ -+function positiveInteger( -+ value: string | undefined, -+ flag: string, -+ maximum?: number, -+): number | undefined { -+ if (value === undefined) return undefined; -+ const parsed = Number(value); -+ if (!Number.isInteger(parsed) || parsed < 1) -+ throw new Error(`${flag} must be a positive integer`); -+ if (maximum !== undefined && parsed > maximum) { -+ throw new Error(`${flag} must not exceed ${maximum}`); -+ } -+ return parsed; -+} -+ -+export async function resolveWatchOptions( -+ repoPath: string, -+ cli: WatchCliOptions, -+ baseline: WatchEnvironmentBaseline, -+ reportIgnoredConfig: (names: readonly string[]) => void = () => {}, -+): Promise { -+ const config = (await loadAnalyzeConfigStrict(repoPath)) ?? {}; -+ const merged = mergeAnalyzeOptions(cli, config); -+ const unsupported = [ -+ ['--force', cli.force], -+ ['--repair-fts', cli.repairFts], -+ ['--embeddings', cli.embeddings], -+ ['--drop-embeddings', cli.dropEmbeddings], -+ ['--skills', cli.skills], -+ ['--default-branch', cli.defaultBranch], -+ ['--skip-agents-md', cli.skipAgentsMd], -+ ['--skip-skills', cli.skipSkills], -+ ['--no-stats', cli.stats === false], -+ ['--self-commit', cli.selfCommit], -+ ['--index-only', cli.indexOnly], -+ ['--skip-git', cli.skipGit], -+ ['--spring-actuator', cli.springActuator], -+ ['walCheckpointThreshold', cli.walCheckpointThreshold], -+ ['embeddingThreads', cli.embeddingThreads], -+ ['embeddingBatchSize', cli.embeddingBatchSize], -+ ['embeddingSubBatchSize', cli.embeddingSubBatchSize], -+ ['embeddingDevice', cli.embeddingDevice], -+ ['embeddingBaseUrl', cli.embeddingBaseUrl], -+ ['embeddingModel', cli.embeddingModel], -+ ['--embedding-auth-token', cli.embeddingAuthToken], -+ ['--embedding-dims', cli.embeddingDims], -+ ].filter(([, value]) => value !== undefined && value !== false); -+ if (unsupported.length > 0) { -+ throw new Error( -+ `analyze --watch does not support ${unsupported.map(([name]) => name).join(', ')}`, -+ ); -+ } -+ reportIgnoredConfig( -+ [ -+ ['embeddings', config.embeddings], -+ ['dropEmbeddings', config.dropEmbeddings], -+ ['defaultBranch', config.defaultBranch], -+ ['skipAgentsMd', config.skipAgentsMd !== undefined], -+ ['skipSkills', config.skipSkills !== undefined], -+ ['stats', config.stats !== undefined], -+ ['springActuator', config.springActuator], -+ ['walCheckpointThreshold', config.walCheckpointThreshold], -+ ['embeddingThreads', config.embeddingThreads], -+ ['embeddingBatchSize', config.embeddingBatchSize], -+ ['embeddingSubBatchSize', config.embeddingSubBatchSize], -+ ['embeddingDevice', config.embeddingDevice], -+ ['embeddingBaseUrl', config.embeddingBaseUrl], -+ ['embeddingModel', config.embeddingModel], -+ ] -+ .filter(([, value]) => value !== undefined && value !== false) -+ .map(([name]) => String(name)), -+ ); -+ const branch = -+ merged.branch === undefined ? undefined : validateBranchName(merged.branch, '--branch'); -+ const workerPoolSize = positiveInteger(merged.workers, '--workers'); -+ const workerTimeoutSeconds = positiveInteger(merged.workerTimeout, 'workerTimeout'); -+ const maxFileSize = positiveInteger(merged.maxFileSize, 'maxFileSize', MAX_FILE_SIZE_KB); -+ -+ setEnvironment( -+ 'GITNEXUS_MAX_FILE_SIZE', -+ maxFileSize === undefined ? baseline.maxFileSize : String(maxFileSize), -+ ); -+ if (workerTimeoutSeconds !== undefined) { -+ process.env.GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS = String(workerTimeoutSeconds * 1000); -+ } else { -+ setEnvironment('GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS', baseline.workerTimeout); -+ } -+ setEnvironment('GITNEXUS_VERBOSE', merged.verbose ? '1' : baseline.verbose); -+ -+ return { -+ pdg: merged.pdg, -+ branch, -+ registryName: merged.name, -+ allowDuplicateName: merged.allowDuplicateName, -+ workerPoolSize, -+ fetchWrappers: merged.fetchWrappers, -+ skipAgentsMd: true, -+ skipSkills: true, -+ noStats: true, -+ atomicIncremental: process.platform !== 'win32', -+ }; -+} -+ -+function refreshSummary( -+ result: AnalyzeResult, -+ observedPaths: readonly string[], -+ durationMs: number, -+ lastSuccessfulRefreshAt: string, -+): string { -+ const measured = result.incrementalStats; -+ const changed = measured?.changedFiles ?? (result.alreadyUpToDate ? 0 : observedPaths.length); -+ const reparsed = -+ measured?.reparsedFiles ?? -+ (typeof result.pipelineResult?.reparsedFileCount === 'number' -+ ? result.pipelineResult.reparsedFileCount -+ : 0); -+ const dependents = measured?.affectedDependents ?? 0; -+ const mode = measured?.writeMode ?? (result.alreadyUpToDate ? 'no-op' : 'full'); -+ return ( -+ `Refresh complete: ${changed} changed, ${reparsed} re-parsed, ` + -+ `${dependents} affected dependent(s), ${durationMs}ms, ${mode}; ` + -+ `last success ${lastSuccessfulRefreshAt}` -+ ); -+} -+ -+async function waitUntilReady(watcher: FSWatcher): Promise { -+ await new Promise((resolve, reject) => { -+ const ready = () => { -+ watcher.off('error', failed); -+ resolve(); -+ }; -+ const failed = (error: unknown) => { -+ watcher.off('ready', ready); -+ reject(error); -+ }; -+ watcher.once('ready', ready); -+ watcher.once('error', failed); -+ }); -+} -+ -+export interface WatchFileLoop { -+ readonly waitForIdle: () => Promise; -+ readonly close: () => Promise; -+} -+ -+class WatchControlReloadError extends Error { -+ constructor(cause: unknown) { -+ super(cause instanceof Error ? cause.message : String(cause), { cause }); -+ this.name = 'WatchControlReloadError'; -+ } -+} -+ -+export function shouldStopAfterWatchRefreshFailure( -+ error: unknown, -+ paths: readonly string[], -+): boolean { -+ return ( -+ paths.length > 0 && -+ !(error instanceof WatchControlReloadError) && -+ analyzeFailureMayHaveMutatedLiveIndex(error) -+ ); -+} -+ -+/** Start the real filesystem watcher with bounded, serialized refreshes. */ -+export async function startWatchFileLoop( -+ repoPath: string, -+ debounceMs: number, -+ refresh: (paths: readonly string[]) => Promise, -+ onError: WatchRefreshError, -+ onWatcherError: (error: unknown) => void = (error) => onError(error, []), -+): Promise { -+ let ignorePath = await createWatchIgnorePredicate(repoPath); -+ let ignoreControlValid = true; -+ const queue = new WatchRefreshQueue( -+ async (paths) => { -+ if (paths.some(isIgnoreControlPath) || !ignoreControlValid) { -+ const retryingInvalidControls = !ignoreControlValid; -+ try { -+ ignorePath = await createWatchIgnorePredicate(repoPath); -+ ignoreControlValid = true; -+ watcher.add(repoPath); -+ } catch (error) { -+ ignoreControlValid = false; -+ throw new WatchControlReloadError( -+ retryingInvalidControls -+ ? new Error( -+ 'Ignore controls remain invalid; fix them before indexing more changes.', -+ { -+ cause: error, -+ }, -+ ) -+ : error, -+ ); -+ } -+ } -+ await refresh(paths); -+ }, -+ onError, -+ debounceMs, -+ { -+ maxWaitMs: Math.max(2_000, debounceMs * 10), -+ maxPendingPaths: 1_000, -+ holdEventsUntilInitialRefresh: true, -+ isPriorityPath: (filePath) => isIgnoreControlPath(filePath) || isConfigControlPath(filePath), -+ }, -+ ); -+ -+ const watcher: FSWatcher = watch(repoPath, { -+ ignoreInitial: true, -+ atomic: true, -+ followSymlinks: false, -+ awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 20 }, -+ ignored: (candidate, stats) => { -+ const relative = repoRelativeWatchPath(repoPath, candidate); -+ if (relative !== null && isAnalyzerOwnedWatchPath(relative)) return true; -+ if (relative !== null && (isIgnoreControlPath(relative) || isConfigControlPath(relative))) { -+ return false; -+ } -+ return ignorePath(candidate, stats?.isDirectory() ?? false); -+ }, -+ }); -+ watcher.on('all', (event, changedPath) => { -+ if (event !== 'add' && event !== 'change' && event !== 'unlink') return; -+ const relative = repoRelativeWatchPath(repoPath, changedPath); -+ if (relative && isRelevantWatchPath(relative) && !isAnalyzerOwnedWatchPath(relative)) { -+ queue.enqueue(relative); -+ } -+ }); -+ watcher.on('error', (error) => { -+ // Chokidar can surface a transient EPERM on Windows while an ignored -+ // analyzer-owned path is replaced. Re-arm the root and force one bounded -+ // catch-up refresh so a missed event cannot leave the graph stale. Other -+ // watcher errors may mean coverage was lost and remain fatal. -+ if (TRANSIENT_WATCH_ERROR_CODES.has((error as NodeJS.ErrnoException).code ?? '')) { -+ watcher.add(repoPath); -+ queue.enqueue(WATCH_FULL_REFRESH_PATH); -+ return; -+ } -+ onWatcherError(error); -+ }); -+ -+ try { -+ await waitUntilReady(watcher); -+ await queue.runInitial(); -+ } catch (error) { -+ await watcher.close(); -+ await queue.close(); -+ throw error; -+ } -+ -+ return { -+ waitForIdle: () => queue.waitForIdle(), -+ close: async () => { -+ await watcher.close(); -+ await queue.close(); -+ }, -+ }; -+} -+ -+export async function watchCommandWithRunnerIdentity( -+ runnerIdentityAtBootstrap: AnalyzerRunnerIdentity, -+ inputPath?: string, -+ cliOptions: WatchCliOptions = {}, -+): Promise { -+ if (await ensureHeap({ cleanForwardedTermination: true })) return; -+ -+ const requestedRepoPath = inputPath ? path.resolve(inputPath) : getGitRoot(process.cwd()); -+ if (requestedRepoPath === null || !hasGitDir(requestedRepoPath)) { -+ cliError(' gitnexus analyze --watch requires a Git repository.'); -+ process.exitCode = 1; -+ return; -+ } -+ const repoPath = await fs.realpath(requestedRepoPath); -+ const baselineEnvironment: WatchEnvironmentBaseline = { -+ maxFileSize: process.env.GITNEXUS_MAX_FILE_SIZE, -+ workerTimeout: process.env.GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS, -+ verbose: process.env.GITNEXUS_VERBOSE, -+ }; -+ try { -+ let ignoredConfigSignature: string | undefined; -+ const reportIgnoredConfig = (names: readonly string[]) => { -+ const signature = [...names].sort().join(','); -+ if (signature === ignoredConfigSignature) return; -+ ignoredConfigSignature = signature; -+ if (names.length > 0) { -+ cliWarn(`Watch mode ignores unsupported .gitnexusrc settings: ${names.join(', ')}.`); -+ } -+ }; -+ let debounceMs: number; -+ let analyzeOptions: CoreAnalyzeOptions; -+ try { -+ debounceMs = -+ positiveInteger( -+ cliOptions.debounce ?? String(DEFAULT_DEBOUNCE_MS), -+ '--debounce', -+ MAX_TIMER_DELAY_MS, -+ ) ?? DEFAULT_DEBOUNCE_MS; -+ analyzeOptions = await resolveWatchOptions( -+ repoPath, -+ cliOptions, -+ baselineEnvironment, -+ reportIgnoredConfig, -+ ); -+ } catch (error) { -+ cliError(` ${error instanceof Error ? error.message : String(error)}`); -+ process.exitCode = 1; -+ return; -+ } -+ -+ let stopWatching!: () => void; -+ const stopped = new Promise((resolve) => { -+ stopWatching = resolve; -+ }); -+ const stop = () => stopWatching(); -+ process.once('SIGINT', stop); -+ process.once('SIGTERM', stop); -+ try { -+ let loop: WatchFileLoop; -+ let fatalRefreshError: unknown; -+ let configControlValid = true; -+ let lastSuccessfulRefreshAt: string | undefined; -+ try { -+ loop = await startWatchFileLoop( -+ repoPath, -+ debounceMs, -+ async (paths) => { -+ if (paths.some(isConfigControlPath) || !configControlValid) { -+ const retryingInvalidConfig = !configControlValid; -+ try { -+ analyzeOptions = await resolveWatchOptions( -+ repoPath, -+ cliOptions, -+ baselineEnvironment, -+ reportIgnoredConfig, -+ ); -+ configControlValid = true; -+ } catch (error) { -+ configControlValid = false; -+ throw new WatchControlReloadError( -+ retryingInvalidConfig -+ ? new Error( -+ 'Configuration remains invalid; fix it before indexing more changes.', -+ { -+ cause: error, -+ }, -+ ) -+ : error, -+ ); -+ } -+ } -+ const startedAt = Date.now(); -+ const result = await runFullAnalysis( -+ repoPath, -+ analyzeOptions, -+ { -+ onProgress: () => {}, -+ onLog: -+ process.env.GITNEXUS_VERBOSE === '1' -+ ? (message) => cliInfo(` ${message}`) -+ : undefined, -+ }, -+ runnerIdentityAtBootstrap, -+ ); -+ lastSuccessfulRefreshAt = new Date().toISOString(); -+ if (paths.length === 0) { -+ cliInfo( -+ result.alreadyUpToDate -+ ? `Watching ${repoPath}; index is up to date.` -+ : `Watching ${repoPath}; initial index ready in ${Date.now() - startedAt}ms.`, -+ ); -+ } else { -+ cliInfo( -+ refreshSummary(result, paths, Date.now() - startedAt, lastSuccessfulRefreshAt), -+ ); -+ } -+ }, -+ (error, paths) => { -+ const detail = paths.length > 0 ? ` (${paths.length} queued path(s))` : ''; -+ if (shouldStopAfterWatchRefreshFailure(error, paths)) { -+ fatalRefreshError = error; -+ cliError( -+ `Refresh failed${detail}: ${error instanceof Error ? error.message : String(error)}. ` + -+ 'Watch mode is stopping because the live index may have been updated in place.', -+ ); -+ stopWatching(); -+ return; -+ } -+ const lastSuccess = lastSuccessfulRefreshAt ?? 'none yet'; -+ cliWarn( -+ `Refresh failed${detail}: ${error instanceof Error ? error.message : String(error)}. ` + -+ `Retry scheduled; last success ${lastSuccess}.`, -+ ); -+ }, -+ (error) => { -+ fatalRefreshError = error; -+ cliError( -+ `Watcher failed: ${error instanceof Error ? error.message : String(error)}. ` + -+ 'Watch mode is stopping.', -+ ); -+ stopWatching(); -+ }, -+ ); -+ } catch (error) { -+ cliError( -+ ` Unable to start watcher: ${error instanceof Error ? error.message : String(error)}`, -+ ); -+ process.exitCode = 1; -+ return; -+ } -+ -+ await stopped; -+ await loop.close(); -+ if (fatalRefreshError !== undefined) process.exitCode = 1; -+ } finally { -+ process.removeListener('SIGINT', stop); -+ process.removeListener('SIGTERM', stop); -+ } -+ } finally { -+ setEnvironment('GITNEXUS_MAX_FILE_SIZE', baselineEnvironment.maxFileSize); -+ setEnvironment('GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS', baselineEnvironment.workerTimeout); -+ setEnvironment('GITNEXUS_VERBOSE', baselineEnvironment.verbose); -+ } - } -diff --git a/gitnexus/src/core/auto-sync/analysis-worker-launch.ts b/gitnexus/src/core/auto-sync/analysis-worker-launch.ts -deleted file mode 100644 -index d57a251a6..000000000 ---- a/gitnexus/src/core/auto-sync/analysis-worker-launch.ts -+++ /dev/null -@@ -1,210 +0,0 @@ --import { fork, type ChildProcess } from 'node:child_process'; --import { existsSync } from 'node:fs'; --import { createRequire } from 'node:module'; --import path from 'node:path'; --import { fileURLToPath, pathToFileURL } from 'node:url'; --import type { AnalyzeOptions, AnalyzeResult } from '../run-analyze.js'; --import type { WorkerMessage } from '../../server/analyze-worker-protocol.js'; --import { autoHeapCapMb } from '../ingestion/utils/effective-ram.js'; -- --const _require = createRequire(import.meta.url); --export type AutoSyncAnalysisRunner = ( -- repoPath: string, -- options: AnalyzeOptions, -- timeoutMs: number, -- signal?: AbortSignal, -- onCancellationRequested?: () => void, -- concurrency?: number, --) => Promise>; -- --interface AnalysisWorker extends Pick { -- stdout?: Pick | null; -- stderr?: Pick | null; -- unref?: () => void; -- channel?: { unref(): void } | null; --} -- --/** -- * How long the parent keeps waiting after asking a worker to cancel. -- * -- * Must stay below `stopAutoSyncWatch`'s process-exit budget, or a worker wedged -- * past its safe point still turns `watch stop` into a timeout. -- */ --const AUTO_SYNC_CANCEL_GRACE_MS = 5_000; -- --export interface AutoSyncAnalysisLaunchDeps { -- forkWorker: (workerPath: string, execArgv: string[]) => AnalysisWorker; -- setTimeoutFn: typeof setTimeout; -- clearTimeoutFn: typeof clearTimeout; -- cancelGraceMs: number; --} -- --const DEFAULT_DEPS: AutoSyncAnalysisLaunchDeps = { -- forkWorker: (workerPath, execArgv) => -- fork(workerPath, [], { -- execArgv, -- stdio: ['ignore', 'pipe', 'pipe', 'ipc'], -- }), -- setTimeoutFn: setTimeout, -- clearTimeoutFn: clearTimeout, -- cancelGraceMs: AUTO_SYNC_CANCEL_GRACE_MS, --}; -- --/** -- * Per-worker V8 heap cap for one tick. -- * -- * `autoHeapCapMb()` is a whole-machine figure, so handing it to every fork -- * over-commits memory by the parallelism factor. Admission already bounds -- * parallelism to `floor(availableMemoryGB / 2)`, so dividing here keeps the sum -- * of worker heaps inside the machine budget while leaving `max_concurrency` -- * free to mean what it says. The two rules compose to a ~1.5GB per-worker floor. -- */ --export function resolveWorkerHeapMb(concurrency = 1): number { -- const slots = Number.isFinite(concurrency) && concurrency >= 1 ? Math.floor(concurrency) : 1; -- return Math.max(1, Math.min(8192, Math.floor(autoHeapCapMb() / slots))); --} -- --export function createAutoSyncAnalysisRunner( -- overrides: Partial = {}, --): AutoSyncAnalysisRunner { -- const deps = { ...DEFAULT_DEPS, ...overrides }; -- return (repoPath, options, timeoutMs, signal, onCancellationRequested, concurrency) => -- new Promise>((resolve, reject) => { -- if (signal?.aborted) { -- reject(new Error('Analysis cancelled.')); -- return; -- } -- const callerPath = fileURLToPath(import.meta.url); -- const isDev = callerPath.endsWith('.ts'); -- const workerPath = path.join( -- path.dirname(callerPath), -- '../../server', -- isDev ? 'analyze-worker.ts' : 'analyze-worker.js', -- ); -- if (!existsSync(workerPath)) { -- reject(new Error(`Auto-sync analyze worker is missing: ${workerPath}`)); -- return; -- } -- const workerHeapMb = resolveWorkerHeapMb(concurrency); -- const execArgv = isDev -- ? [ -- '--import', -- pathToFileURL(_require.resolve('tsx/esm')).href, -- `--max-old-space-size=${workerHeapMb}`, -- ] -- : [`--max-old-space-size=${workerHeapMb}`]; -- const child = deps.forkWorker(workerPath, execArgv); -- child.stdout?.resume(); -- child.stderr?.resume(); -- -- let terminalOutcome: WorkerMessage | undefined; -- let terminationError: Error | undefined; -- let settled = false; -- let graceTimer: ReturnType | undefined; -- const cleanup = () => { -- deps.clearTimeoutFn(timeout); -- deps.clearTimeoutFn(graceTimer); -- signal?.removeEventListener('abort', onAbort); -- }; -- // Stop the parent owning a worker it has given up waiting for. An -- // established IPC channel keeps this event loop alive even after unref, -- // so both handles have to go. Never a kill: the child may be inside -- // native work and is left to reach its own safe point. -- const releaseChild = () => { -- child.channel?.unref?.(); -- child.unref?.(); -- }; -- const settle = (error?: Error, result?: Pick) => { -- if (settled) return; -- settled = true; -- cleanup(); -- if (error) reject(error); -- else resolve(result!); -- }; -- const requestCancellation = (error: Error) => { -- if (settled || terminationError) return; -- terminationError = error; -- deps.clearTimeoutFn(timeout); -- onCancellationRequested?.(); -- // IPC has the same semantics on macOS and Windows. The worker exits only -- // after reaching a JS-visible safe point; this parent keeps ownership until then. -- try { -- child.send({ type: 'cancel' }); -- } catch { -- // A closed IPC channel still has an exit/error path. Do not force-kill a -- // worker that may be inside native code. -- } -- // Bounded wait. A worker stuck past its safe point would otherwise leave -- // this promise pending forever, wedging `activeRun` so `stop()` — and the -- // `watch stop` waiting on this process to exit — can never finish. Settle -- // the parent's wait and drop the IPC channel's hold on this event loop; -- // an established channel keeps the parent alive even after unref. The -- // child is deliberately left running rather than killed mid-write. -- graceTimer = deps.setTimeoutFn(() => { -- if (settled) return; -- releaseChild(); -- settle( -- new Error( -- `${error.message} The analyze worker did not exit within ${deps.cancelGraceMs}ms; ` + -- 'it was left running so its native work is not interrupted.', -- ), -- ); -- }, deps.cancelGraceMs); -- }; -- const timeout = deps.setTimeoutFn( -- () => requestCancellation(new Error(`Analysis timed out after ${timeoutMs}ms.`)), -- timeoutMs, -- ); -- const onAbort = () => requestCancellation(new Error('Analysis cancelled.')); -- signal?.addEventListener('abort', onAbort, { once: true }); -- -- child.on('message', (message: WorkerMessage) => { -- // Once timeout/cancellation requested shutdown, its reason owns the -- // result. A terminal IPC can already be queued behind cancellation. -- if (message.type === 'progress' || terminalOutcome || terminationError) return; -- terminalOutcome = message; -- deps.clearTimeoutFn(timeout); -- }); -- child.on('error', (error) => { -- const workerError = new Error(`Auto-sync analyze worker error: ${error.message}`); -- requestCancellation(workerError); -- // This settles immediately rather than waiting out the grace, so the -- // grace timer that would otherwise have released the child is cleared -- // by cleanup(). Release it here instead — an errored channel does not -- // mean the worker stopped. -- releaseChild(); -- settle(workerError); -- }); -- child.on('exit', (code, childSignal) => { -- if (settled) return; -- if (terminationError) { -- settle(terminationError); -- return; -- } -- if (terminalOutcome?.type === 'complete') { -- settle(undefined, { stats: terminalOutcome.result.stats }); -- return; -- } -- if (terminalOutcome?.type === 'error') { -- settle(new Error(terminalOutcome.message)); -- return; -- } -- settle( -- new Error( -- `Auto-sync analyze worker exited before completion (${childSignal ?? code ?? 'unknown'}).`, -- ), -- ); -- }); -- try { -- child.send({ type: 'start', repoPath, options }); -- } catch (error) { -- const startError = new Error( -- `Failed to start auto-sync analyze worker: ${(error as Error).message}`, -- ); -- requestCancellation(startError); -- settle(startError); -- } -- }); --} -- --export const runAutoSyncAnalysis = createAutoSyncAnalysisRunner(); -diff --git a/gitnexus/src/core/auto-sync/config.ts b/gitnexus/src/core/auto-sync/config.ts -deleted file mode 100644 -index fa91aa8b4..000000000 ---- a/gitnexus/src/core/auto-sync/config.ts -+++ /dev/null -@@ -1,367 +0,0 @@ --import fs from 'node:fs/promises'; --import path from 'node:path'; --import { createRequire } from 'node:module'; --import { getGlobalDir } from '../../storage/repo-manager.js'; --import { normalizeConfiguredCloneRoot } from './path-security.js'; -- --const _require = createRequire(import.meta.url); --const yaml = _require('js-yaml') as typeof import('js-yaml'); -- --export const AUTO_SYNC_CONFIG_FILE = 'watch_config.yml'; --const GROUP_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]*$/; --const MIN_SYNC_INTERVAL_MINUTES = 5; --const MAX_TIMER_DELAY_MS = 2_147_483_647; --const MAX_SYNC_INTERVAL_MINUTES = Math.floor(MAX_TIMER_DELAY_MS / 60_000); --const DEFAULT_REPO_GIT_TIMEOUT_MS = 10_000; --const DEFAULT_MAX_CONCURRENCY = 1; --export const DEFAULT_ANALYZE_FAILURE_THRESHOLD = 3; --const MIN_ANALYZE_FAILURE_THRESHOLD = 2; --const ALLOWED_REMOTE_HOSTS = new Set(['github.com', 'gitlab.com', 'gitee.com']); -- --/** -- * A single clone/pull must fit inside one sync interval and inside an hour. -- * This is also the guard for the unit slip the bare-number rule invites: -- * `repo_git_timeout: 600000` means 600000 SECONDS (~7 days), which clears the -- * Node timer ceiling and would silently disable the timeout. -- */ --const MAX_REPO_GIT_TIMEOUT_MS = 3_600_000; -- --// Mirrors REPO_NAME_PATTERN in server/git-clone.ts. Deliberately duplicated --// rather than imported: git-clone.ts already imports from this module, so the --// reverse edge would be a cycle. --const REMOTE_REPO_NAME_PATTERN = /^[a-zA-Z0-9._-]+$/; --// Same charset for a namespace segment: GitLab subgroups allow exactly these, --// and excluding separators is what stops a segment smuggling in traversal. --const REMOTE_PATH_SEGMENT_PATTERN = REMOTE_REPO_NAME_PATTERN; -- --export interface AutoSyncProjectConfig { -- localPath: string; -- groupName?: string; -- overwriteLocalChanges: boolean; -- branches: string[]; -- remoteUrls: string[]; --} -- --export interface AutoSyncConfig { -- configPath: string; -- syncIntervalMinutes: number; -- repoGitTimeoutMs: number; -- analyzeTimeoutMs: number; -- maxConcurrency: number; -- analyzeFailureThreshold: number; -- projects: AutoSyncProjectConfig[]; --} -- --export type AutoSyncConfigLoadResult = -- | { ok: true; config: AutoSyncConfig } -- | { ok: false; reason: 'missing' | 'unreadable' | 'invalid'; message: string }; -- --export function getAutoSyncConfigPath(gitnexusDir = getGlobalDir()): string { -- return path.join(gitnexusDir, AUTO_SYNC_CONFIG_FILE); --} -- --export function parseBranchCandidates(branchValue: unknown): string[] { -- const rawItems = Array.isArray(branchValue) -- ? branchValue.flatMap((item) => String(item).split(',')) -- : String(branchValue ?? '').split(','); -- const branches: string[] = []; -- const seen = new Set(); -- for (const item of rawItems) { -- const branch = item.trim(); -- if (!branch || seen.has(branch)) continue; -- seen.add(branch); -- branches.push(branch); -- } -- return branches; --} -- --export async function loadAutoSyncConfig( -- configPath = getAutoSyncConfigPath(), --): Promise { -- let content: string; -- try { -- content = await fs.readFile(configPath, 'utf-8'); -- } catch (err: unknown) { -- const code = (err as NodeJS.ErrnoException).code; -- if (code === 'ENOENT') { -- return { -- ok: false, -- reason: 'missing', -- message: `[auto-sync] Missing config file: ${configPath}. Auto sync is skipped.`, -- }; -- } -- return { -- ok: false, -- reason: 'unreadable', -- message: `[auto-sync] Unable to read config file: ${configPath}. Auto sync is skipped.`, -- }; -- } -- -- try { -- return { ok: true, config: parseAutoSyncConfig(content, configPath) }; -- } catch (err: unknown) { -- return { -- ok: false, -- reason: 'invalid', -- message: `[auto-sync] Invalid watch_config.yml: ${(err as Error).message}. Auto sync is skipped.`, -- }; -- } --} -- --export function parseAutoSyncConfig(content: string, configPath: string): AutoSyncConfig { -- const raw = yaml.load(content, { schema: yaml.JSON_SCHEMA }) as Record; -- if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { -- throw new Error('expected a YAML object'); -- } -- -- const errors: string[] = []; -- const interval = Number(raw.sync_interval_minutes); -- if (!Number.isInteger(interval) || interval <= 0) { -- errors.push('sync_interval_minutes must be a positive integer'); -- } else if (interval < MIN_SYNC_INTERVAL_MINUTES) { -- errors.push(`sync_interval_minutes must be at least ${MIN_SYNC_INTERVAL_MINUTES}`); -- } else if (interval > MAX_SYNC_INTERVAL_MINUTES) { -- errors.push(`sync_interval_minutes must not exceed ${MAX_SYNC_INTERVAL_MINUTES}`); -- } -- -- // YAML booleans survive JSON_SCHEMA (`true`/`false`). `Number(true) === 1` -- // would otherwise pass the integer check and silently mean concurrency 1. -- let maxConcurrency = DEFAULT_MAX_CONCURRENCY; -- if (raw.max_concurrency !== undefined) { -- if (typeof raw.max_concurrency !== 'number' || !Number.isInteger(raw.max_concurrency)) { -- errors.push('max_concurrency must be a positive integer'); -- } else if (raw.max_concurrency <= 0) { -- errors.push('max_concurrency must be a positive integer'); -- } else { -- maxConcurrency = raw.max_concurrency; -- } -- } -- -- const repoGitTimeoutMs = -- raw.repo_git_timeout === undefined -- ? DEFAULT_REPO_GIT_TIMEOUT_MS -- : parseDurationMs(raw.repo_git_timeout); -- const maxRepoGitTimeoutMs = -- Number.isInteger(interval) && -- interval >= MIN_SYNC_INTERVAL_MINUTES && -- interval <= MAX_SYNC_INTERVAL_MINUTES -- ? Math.min(interval * 60_000, MAX_REPO_GIT_TIMEOUT_MS) -- : undefined; -- if (!Number.isInteger(repoGitTimeoutMs) || repoGitTimeoutMs <= 0) { -- errors.push('repo_git_timeout must be a positive duration such as 10s'); -- } else if (repoGitTimeoutMs > MAX_TIMER_DELAY_MS) { -- errors.push(`repo_git_timeout must not exceed ${MAX_TIMER_DELAY_MS}ms`); -- } else if (maxRepoGitTimeoutMs !== undefined && repoGitTimeoutMs > maxRepoGitTimeoutMs) { -- errors.push( -- `repo_git_timeout must not exceed ${maxRepoGitTimeoutMs}ms (the lesser of 1h and ` + -- `sync_interval_minutes); a bare number is interpreted as seconds, so use an explicit ` + -- `unit such as 600000ms or 10m`, -- ); -- } -- -- const maxAnalyzeTimeoutMs = -- Number.isInteger(interval) && -- interval >= MIN_SYNC_INTERVAL_MINUTES && -- interval <= MAX_SYNC_INTERVAL_MINUTES -- ? interval * 30_000 -- : undefined; -- const analyzeTimeoutMs = -- raw.analyze_timeout === undefined -- ? (maxAnalyzeTimeoutMs ?? 0) -- : parseDurationMs(raw.analyze_timeout); -- if (!Number.isInteger(analyzeTimeoutMs) || analyzeTimeoutMs <= 0) { -- errors.push('analyze_timeout must be a positive duration such as 30m'); -- } else if (maxAnalyzeTimeoutMs !== undefined && analyzeTimeoutMs > maxAnalyzeTimeoutMs) { -- errors.push( -- `analyze_timeout must not exceed half of sync_interval_minutes (${maxAnalyzeTimeoutMs / 60_000}m)`, -- ); -- } -- -- const analyzeFailureThreshold = -- raw.analyze_failure_threshold === undefined -- ? DEFAULT_ANALYZE_FAILURE_THRESHOLD -- : Number(raw.analyze_failure_threshold); -- if ( -- !Number.isInteger(analyzeFailureThreshold) || -- analyzeFailureThreshold < MIN_ANALYZE_FAILURE_THRESHOLD -- ) { -- errors.push(`analyze_failure_threshold must be an integer >= ${MIN_ANALYZE_FAILURE_THRESHOLD}`); -- } -- -- const rawProjects = raw.projects; -- if (!Array.isArray(rawProjects) || rawProjects.length === 0) { -- errors.push('projects must contain at least one project'); -- } -- -- const projects: AutoSyncProjectConfig[] = []; -- if (Array.isArray(rawProjects)) { -- rawProjects.forEach((projectValue, index) => { -- const project = projectValue as Record; -- if (!project || typeof project !== 'object' || Array.isArray(project)) { -- errors.push(`projects[${index}] must be an object`); -- return; -- } -- -- const localPath = typeof project.local_path === 'string' ? project.local_path.trim() : ''; -- if (!localPath) { -- errors.push(`projects[${index}].local_path is required`); -- } else { -- try { -- normalizeConfiguredCloneRoot(localPath); -- } catch (err: unknown) { -- errors.push(`projects[${index}].local_path ${(err as Error).message}`); -- } -- } -- -- const remoteUrls = Array.isArray(project.remote_urls) -- ? project.remote_urls.map((url) => String(url).trim()).filter(Boolean) -- : []; -- if (remoteUrls.length === 0) { -- errors.push(`projects[${index}].remote_urls must contain at least one URL`); -- } -- for (let urlIndex = 0; urlIndex < remoteUrls.length; urlIndex += 1) { -- try { -- validateAutoSyncRemoteUrl(remoteUrls[urlIndex]); -- } catch (err: unknown) { -- errors.push(`projects[${index}].remote_urls[${urlIndex}] ${(err as Error).message}`); -- } -- } -- -- if (project.branch !== undefined && project.branches !== undefined) { -- errors.push(`projects[${index}] must not set both branch and branches`); -- } -- const branches = parseBranchCandidates( -- project.branches !== undefined ? project.branches : project.branch, -- ); -- if (branches.length === 0) errors.push(`projects[${index}].branches is required`); -- for (let branchIndex = 0; branchIndex < branches.length; branchIndex += 1) { -- try { -- validateAutoSyncBranchName(branches[branchIndex]); -- } catch (err: unknown) { -- errors.push(`projects[${index}].branches[${branchIndex}] ${(err as Error).message}`); -- } -- } -- -- const groupName = -- typeof project.group_name === 'string' && project.group_name.trim() -- ? project.group_name.trim() -- : undefined; -- if (groupName && !GROUP_NAME_PATTERN.test(groupName)) { -- errors.push(`projects[${index}].group_name is invalid`); -- } -- -- const overwriteLocalChanges = -- project.overwrite_local_changes === undefined ? false : project.overwrite_local_changes; -- if (typeof overwriteLocalChanges !== 'boolean') { -- errors.push(`projects[${index}].overwrite_local_changes must be a boolean`); -- } -- -- if (localPath && remoteUrls.length > 0 && branches.length > 0) { -- projects.push({ -- localPath, -- groupName, -- overwriteLocalChanges: overwriteLocalChanges === true, -- branches, -- remoteUrls, -- }); -- } -- }); -- } -- -- if (errors.length > 0) throw new Error(errors.join('; ')); -- return { -- configPath, -- syncIntervalMinutes: interval, -- repoGitTimeoutMs, -- analyzeTimeoutMs, -- maxConcurrency, -- analyzeFailureThreshold, -- projects, -- }; --} -- --export function validateAutoSyncRemoteUrl(remoteUrl: string): void { -- const trimmed = remoteUrl.trim(); -- if (trimmed.includes('?') || trimmed.includes('#')) { -- throw new Error('must not include query strings or fragments'); -- } -- const match = /^git@([^:\s/]+):([^\s]+)$/.exec(trimmed); -- if (!match) { -- throw new Error('must use an SSH URL on github.com, gitlab.com, or gitee.com'); -- } -- const host = match[1].toLowerCase(); -- const repoPath = match[2]; -- if (!ALLOWED_REMOTE_HOSTS.has(host)) { -- throw new Error('host must be one of github.com, gitlab.com, or gitee.com'); -- } -- const pathParts = repoPath.split('/'); -- // Every segment becomes a directory component: the namespace segments build -- // the clone path and the last one names the repo. So each is held to the same -- // charset, which is what keeps a separator out of a segment — on Windows -- // `..\..\outside` is traversal even though the segment is not literally `..`, -- // and testing the raw string for `..` instead would reject an ordinary -- // `foo..bar`. Traversal is a whole segment; a separator is a character. -- const namespaceParts = pathParts.slice(0, -1); -- if ( -- repoPath.startsWith('/') || -- pathParts.length < 2 || -- pathParts.some((part) => !part || part === '.' || part === '..') || -- namespaceParts.some((part) => !REMOTE_PATH_SEGMENT_PATTERN.test(part)) -- ) { -- throw new Error('path must include owner/repo without traversal'); -- } -- // The final segment becomes the on-disk clone directory via `extractRepoName`, -- // whose name rules are stricter than the path check above: a backslash — or -- // anything outside `[A-Za-z0-9._-]` — passes here and then throws once per -- // tick inside the sync loop instead of at config load. These rules are a -- // strict superset, so anything accepted here is accepted there. -- const lastSegment = pathParts[pathParts.length - 1]; -- const repoName = /\.git$/i.test(lastSegment) ? lastSegment.slice(0, -4) : lastSegment; -- if ( -- !repoName || -- repoName === '.' || -- repoName === '..' || -- repoName === 'unknown' || -- repoName.startsWith('-') || -- !REMOTE_REPO_NAME_PATTERN.test(repoName) -- ) { -- throw new Error( -- 'repository name must use only letters, digits, ".", "_", or "-" and must not be "unknown"', -- ); -- } --} -- --export function validateAutoSyncBranchName(branch: string): void { -- if (!branch.trim()) throw new Error('must not be empty'); -- if (/[\s\0-\x1f\x7f]/.test(branch)) -- throw new Error('must not contain whitespace or control characters'); -- if (/[~^:?*[\\]/.test(branch)) throw new Error('contains characters not allowed in a git ref'); -- if (branch.startsWith('-')) throw new Error('must not start with "-"'); -- if (branch.startsWith('/')) throw new Error('must not start with "/"'); -- if (branch.includes('..')) throw new Error('must not contain ".."'); -- if (branch.includes('`')) throw new Error('must not contain backticks'); -- if (branch.endsWith('/') || branch.endsWith('.')) throw new Error('must not end with "/" or "."'); -- if (branch.includes('//')) throw new Error('must not contain consecutive slashes'); -- if (branch.includes('@{')) throw new Error('must not contain "@{"'); -- if ( -- branch -- .split('/') -- .some( -- (component) => -- component.startsWith('.') || component.endsWith('.') || component.endsWith('.lock'), -- ) -- ) -- throw new Error('must not contain hidden, trailing-dot, or .lock path components'); --} -- --export function parseDurationMs(value: unknown): number { -- if (typeof value === 'number') return value * 1_000; -- const raw = String(value ?? '').trim(); -- const match = /^(\d+)(ms|s|m)?$/.exec(raw); -- if (!match) return Number.NaN; -- const amount = Number(match[1]); -- const unit = match[2] ?? 's'; -- if (unit === 'ms') return amount; -- if (unit === 's') return amount * 1_000; -- return amount * 60_000; --} -diff --git a/gitnexus/src/core/auto-sync/index.ts b/gitnexus/src/core/auto-sync/index.ts -deleted file mode 100644 -index b02948779..000000000 ---- a/gitnexus/src/core/auto-sync/index.ts -+++ /dev/null -@@ -1,57 +0,0 @@ --export { -- AUTO_SYNC_CONFIG_FILE, -- getAutoSyncConfigPath, -- loadAutoSyncConfig, -- parseAutoSyncConfig, -- parseBranchCandidates, -- parseDurationMs, -- validateAutoSyncBranchName, -- validateAutoSyncRemoteUrl, -- type AutoSyncConfig, -- type AutoSyncConfigLoadResult, -- type AutoSyncProjectConfig, --} from './config.js'; --export { -- buildStateKey, -- getAutoSyncMutexPath, -- getAutoSyncWatchDir, -- getAutoSyncStatePath, -- getProjectCommitInfoPath, -- loadAutoSyncState, -- resetAutoSyncState, -- saveAutoSyncState, -- shouldAnalyzeCommit, -- writeProjectCommitInfo, -- type AutoSyncAnalyzeStatus, -- type AutoSyncCommitState, -- type AutoSyncCommitStateEntry, -- type ProjectCommitInfoEntry, --} from './state.js'; --export { extractRepoNameFromRemoteUrl } from './repo.js'; --export { -- normalizeConfiguredCloneRoot, -- quarantineAutoSyncPartial, -- resolveConfiguredCloneRoot, -- type AutoSyncCloneRoot, --} from './path-security.js'; --export { -- addRepoToGroup, -- getAutoSyncRepoIdentity, -- getConfiguredRepoPath, -- resolveActualConcurrency, -- runAutoSyncOnce, -- syncGroupByName, -- type AutoSyncLogger, -- type AutoSyncRunDeps, -- type AutoSyncRunResult, --} from './runner.js'; --export { -- getAutoSyncWatchPaths, -- readAutoSyncWatchStatus, -- startAutoSyncWatch, -- stopAutoSyncWatch, -- type AutoSyncStartHandle, -- type AutoSyncWatchStopResult, -- type AutoSyncWatchPaths, -- type WatchStatusRecord, --} from './starter.js'; -diff --git a/gitnexus/src/core/auto-sync/path-security.ts b/gitnexus/src/core/auto-sync/path-security.ts -deleted file mode 100644 -index ed9d9201b..000000000 ---- a/gitnexus/src/core/auto-sync/path-security.ts -+++ /dev/null -@@ -1,286 +0,0 @@ --import fs from 'node:fs/promises'; --import { randomUUID } from 'node:crypto'; --import os from 'node:os'; --import path from 'node:path'; --import { getGlobalDir } from '../../storage/repo-manager.js'; --import { getAutoSyncWatchDir } from './state.js'; -- --const WINDOWS_DANGEROUS_ROOTS = -- process.platform === 'win32' -- ? [ -- process.env.SystemRoot, -- process.env.ProgramData, -- process.env.ProgramFiles, -- process.env['ProgramFiles(x86)'], -- ].filter((entry): entry is string => Boolean(entry)) -- : []; -- --const DANGEROUS_ROOTS = new Set( -- [ -- '/', -- os.homedir(), -- os.tmpdir(), -- '/bin', -- '/boot', -- '/dev', -- '/etc', -- '/lib', -- '/lib64', -- '/opt', -- '/proc', -- '/private/tmp', -- '/private/var', -- '/root', -- '/sbin', -- '/sys', -- '/tmp', -- '/usr', -- '/var', -- ...WINDOWS_DANGEROUS_ROOTS, -- ].map((entry) => path.resolve(entry)), --); -- --const DANGEROUS_PARENT_ROOTS = new Set( -- [ -- os.tmpdir(), -- '/bin', -- '/boot', -- '/dev', -- '/etc', -- '/lib', -- '/lib64', -- '/opt', -- '/proc', -- '/private/tmp', -- '/private/var', -- '/root', -- '/sbin', -- '/sys', -- '/tmp', -- '/usr', -- '/var', -- ...WINDOWS_DANGEROUS_ROOTS, -- ].map((entry) => path.resolve(entry)), --); -- --const QUARANTINE_RETENTION_DAYS = 14; --const QUARANTINE_MAX_ENTRIES_PER_REPO = 5; -- --// `auto-sync----` — see quarantineAutoSyncPartial. --// The UUID is the only fixed-shape field, so it anchors the grouping key, and --// everything after it is the basename (`[A-Za-z0-9._-]` by construction). --const QUARANTINE_ENTRY_PATTERN = -- /^auto-sync-.+-\d+-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}-(.+)$/i; -- --export interface AutoSyncCloneRoot { -- root: string; -- quarantineRoot: string; -- quarantineRetentionDays: number; --} -- --export async function resolveConfiguredCloneRoot(localPath: string): Promise { -- const root = normalizeConfiguredCloneRoot(localPath); -- assertNotDangerousRoot(root); -- await assertNoSymlinkPath(root); -- await fs.mkdir(root, { recursive: true }); -- await assertDirectoryOwnerAndPermissions(root); -- const realRoot = await fs.realpath(root); -- assertContainedOrSame( -- root, -- realRoot, -- 'Configured clone root realpath escaped its normalized path', -- ); -- assertNotDangerousRoot(realRoot); -- assertNotGitNexusInternalRoot(realRoot); -- const quarantineRoot = path.join(getAutoSyncWatchDir(), 'quarantine'); -- await pruneQuarantineEntries(quarantineRoot); -- -- return { -- root: realRoot, -- quarantineRoot, -- quarantineRetentionDays: QUARANTINE_RETENTION_DAYS, -- }; --} -- --export function normalizeConfiguredCloneRoot(localPath: string): string { -- const value = localPath.trim(); -- if (!value) throw new Error('local_path is required'); -- if (!path.isAbsolute(value)) throw new Error('local_path must be an absolute path'); -- if (value.split(path.sep).includes('..')) { -- throw new Error('local_path must be normalized and must not contain traversal segments'); -- } -- const resolved = path.resolve(value); -- if (resolved !== path.normalize(value)) { -- throw new Error('local_path must be normalized and must not contain traversal segments'); -- } -- return resolved; --} -- --export async function quarantineAutoSyncPartial( -- targetDir: string, -- quarantineRoot: string, --): Promise { -- await fs.mkdir(quarantineRoot, { recursive: true, mode: 0o700 }); -- const base = path.basename(targetDir); -- const stamp = new Date().toISOString().replace(/[:.]/g, '-'); -- const destination = path.join( -- quarantineRoot, -- `auto-sync-${stamp}-${process.pid}-${randomUUID()}-${base}`, -- ); -- try { -- await fs.rename(targetDir, destination); -- } catch (err: unknown) { -- if ((err as NodeJS.ErrnoException).code !== 'EXDEV') throw err; -- await fs.cp(targetDir, destination, { recursive: true }); -- await fs.rm(targetDir, { recursive: true, force: true }); -- } -- await fs.writeFile( -- `${destination}.README.txt`, -- [ -- 'GitNexus auto-sync isolated a partial or unsafe clone result.', -- `Created at: ${new Date().toISOString()}`, -- `Original path: ${targetDir}`, -- `Retention: keep for ${QUARANTINE_RETENTION_DAYS} days unless an operator reviews and removes it earlier.`, -- 'Cleanup: verify the original path and remote before manual deletion.', -- '', -- ].join('\n'), -- 'utf-8', -- ); -- return destination; --} -- --async function pruneQuarantineEntries(quarantineRoot: string): Promise { -- const cutoff = Date.now() - QUARANTINE_RETENTION_DAYS * 24 * 60 * 60 * 1_000; -- // readdir and stat both resolve through a link, so a symlinked quarantine -- // root would age-sweep and delete entries somewhere else entirely. -- const rootStat = await fs.lstat(quarantineRoot).catch(() => undefined); -- if (rootStat?.isSymbolicLink()) { -- throw new Error(`Refusing symlinked auto-sync quarantine root: ${quarantineRoot}`); -- } -- let entries; -- try { -- entries = await fs.readdir(quarantineRoot); -- } catch (err: unknown) { -- if ((err as NodeJS.ErrnoException).code === 'ENOENT') return; -- throw err; -- } -- const survivors = ( -- await Promise.all( -- entries -- .filter((entry) => entry.startsWith('auto-sync-')) -- .map(async (entry) => { -- const entryPath = path.join(quarantineRoot, entry); -- const stat = await fs.stat(entryPath).catch(() => undefined); -- if (stat && stat.mtimeMs < cutoff) { -- await fs.rm(entryPath, { recursive: true, force: true }); -- return undefined; -- } -- return entry; -- }), -- ) -- ).filter((entry): entry is string => entry !== undefined); -- -- // Age alone never bounds a repo that fails on every tick: one partial clone -- // per tick stays inside the retention window forever. Keep the newest few per -- // repo. Entries that do not match the generated naming scheme (operator -- // notes, names from another version) are left to the age sweep alone. -- const byRepo = new Map(); -- for (const entry of survivors) { -- if (entry.endsWith('.README.txt')) continue; -- const repo = QUARANTINE_ENTRY_PATTERN.exec(entry)?.[1]; -- if (!repo) continue; -- const group = byRepo.get(repo) ?? []; -- group.push(entry); -- byRepo.set(repo, group); -- } -- await Promise.all( -- [...byRepo.values()].flatMap((group) => -- group -- // The timestamp is the leading fixed-width field, so a descending -- // string sort is newest-first. -- .sort((a, b) => (a < b ? 1 : a > b ? -1 : 0)) -- .slice(QUARANTINE_MAX_ENTRIES_PER_REPO) -- .map(async (entry) => { -- await fs.rm(path.join(quarantineRoot, entry), { recursive: true, force: true }); -- await fs.rm(path.join(quarantineRoot, `${entry}.README.txt`), { force: true }); -- }), -- ), -- ); --} -- --function assertNotDangerousRoot(root: string): void { -- if (root === path.resolve(getGlobalDir(), 'repos')) return; -- if (DANGEROUS_ROOTS.has(root)) throw new Error(`Refusing unsafe auto-sync clone root: ${root}`); -- for (const dangerousRoot of DANGEROUS_PARENT_ROOTS) { -- const rel = path.relative(dangerousRoot, root); -- if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) { -- throw new Error(`Refusing unsafe auto-sync clone root under ${dangerousRoot}: ${root}`); -- } -- } -- if (path.parse(root).root === root) -- throw new Error(`Refusing filesystem root as clone root: ${root}`); --} -- --function assertNotGitNexusInternalRoot(root: string): void { -- const gitnexusDir = path.resolve(getGlobalDir()); -- const blocked = [ -- path.join(gitnexusDir, 'groups'), -- path.join(gitnexusDir, 'indexes'), -- path.join(gitnexusDir, 'quarantine'), -- path.join(getAutoSyncWatchDir(gitnexusDir), 'quarantine'), -- ]; -- for (const blockedRoot of blocked) { -- const rel = path.relative(blockedRoot, root); -- if (!rel || (!rel.startsWith('..') && !path.isAbsolute(rel))) { -- throw new Error(`Refusing GitNexus internal directory as auto-sync clone root: ${root}`); -- } -- } --} -- --async function assertNoSymlinkPath(root: string): Promise { -- const parsed = path.parse(root); -- let current = parsed.root; -- const parts = root.slice(parsed.root.length).split(path.sep).filter(Boolean); -- for (const part of parts) { -- current = path.join(current, part); -- let stat; -- try { -- stat = await fs.lstat(current); -- } catch (err: unknown) { -- if ((err as NodeJS.ErrnoException).code === 'ENOENT') break; -- throw err; -- } -- if (stat.isSymbolicLink()) -- throw new Error(`Refusing symlink in auto-sync clone root path: ${current}`); -- } --} -- --export async function assertDirectoryOwnerAndPermissions(root: string): Promise { -- const stat = await fs.stat(root); -- if (!stat.isDirectory()) throw new Error(`auto-sync clone root is not a directory: ${root}`); -- // POSIX uid/mode have no meaning on Windows, and this runs on every tick for -- // every project, so throwing here failed 100% of repos forever while `watch -- // status` still read `running`. Skip the ownership assertions rather than the -- // whole feature: the caller's other guards — dangerous-root rejection -- // (including the Windows system roots), symlink refusal, realpath containment -- // and the GitNexus-internal-root check — all still apply, and managed git runs -- // with `core.hooksPath` pinned to the null device. -- if (process.platform === 'win32') return; -- if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) { -- throw new Error(`auto-sync clone root is owned by uid ${stat.uid}, not current process uid`); -- } -- const mode = stat.mode & 0o777; -- const groupWritable = (mode & 0o020) !== 0; -- const worldWritable = (mode & 0o002) !== 0; -- if (worldWritable) { -- throw new Error(`Refusing world-writable auto-sync clone root: ${root}`); -- } -- if (groupWritable) { -- throw new Error(`Refusing group-writable auto-sync clone root: ${root}`); -- } --} -- --function assertContainedOrSame(root: string, child: string, message: string): void { -- const rel = path.relative(root, child); -- if (rel.startsWith('..') || path.isAbsolute(rel)) throw new Error(message); --} -diff --git a/gitnexus/src/core/auto-sync/repo.ts b/gitnexus/src/core/auto-sync/repo.ts -deleted file mode 100644 -index ed8e1731d..000000000 ---- a/gitnexus/src/core/auto-sync/repo.ts -+++ /dev/null -@@ -1,7 +0,0 @@ --import { extractRepoName } from '../../server/git-clone.js'; --import { validateAutoSyncRemoteUrl } from './config.js'; -- --export function extractRepoNameFromRemoteUrl(remoteUrl: string): string { -- validateAutoSyncRemoteUrl(remoteUrl); -- return extractRepoName(remoteUrl); --} -diff --git a/gitnexus/src/core/auto-sync/runner.ts b/gitnexus/src/core/auto-sync/runner.ts -deleted file mode 100644 -index ed5f7afd7..000000000 ---- a/gitnexus/src/core/auto-sync/runner.ts -+++ /dev/null -@@ -1,561 +0,0 @@ --import fs from 'node:fs/promises'; --import path from 'node:path'; --import { createRequire } from 'node:module'; --import { loadGroupConfig } from '../group/config-parser.js'; --import { getDefaultGitnexusDir, getGroupDir } from '../group/storage.js'; --import { syncGroup } from '../group/sync.js'; --import { registerRepo, resolveBranchPlacement, type RepoMeta } from '../../storage/repo-manager.js'; --import { extractRepoNameFromRemoteUrl } from './repo.js'; --import { cloneOrPull, runGit } from '../../server/git-clone.js'; --import { resolveConfiguredCloneRoot } from './path-security.js'; --import { -- buildStateKey, -- loadAutoSyncState, -- saveAutoSyncState, -- shouldAnalyzeCommit, -- writeProjectCommitInfo, -- type AutoSyncAnalyzeStatus, -- type AutoSyncCommitStateEntry, -- type ProjectCommitInfoEntry, --} from './state.js'; --import type { AutoSyncConfig, AutoSyncProjectConfig } from './config.js'; --import { validateAutoSyncRemoteUrl } from './config.js'; --import { runAutoSyncAnalysis, type AutoSyncAnalysisRunner } from './analysis-worker-launch.js'; -- --export interface AutoSyncLogger { -- info(message: string): void; -- warn(message: string): void; -- error(message: string): void; --} -- --export interface AutoSyncRunDeps { -- cloneOrPull: typeof cloneOrPull; -- getCurrentBranch: (repoPath: string, timeoutMs: number) => Promise; -- getCurrentCommit: (repoPath: string, timeoutMs: number) => Promise; -- runAnalysis: AutoSyncAnalysisRunner; -- registerRepo: typeof registerRepo; -- resolveBranchPlacement: typeof resolveBranchPlacement; -- loadState: typeof loadAutoSyncState; -- saveState: typeof saveAutoSyncState; -- writeCommitInfo: typeof writeProjectCommitInfo; -- addRepoToGroup: typeof addRepoToGroup; -- syncGroupByName: typeof syncGroupByName; -- resolveCloneRoot: typeof resolveConfiguredCloneRoot; -- getAvailableMemoryGB: () => number; --} -- --export interface AutoSyncRunResult { -- synced: number; -- analyzed: number; -- skippedAnalysis: number; -- failed: number; --} -- --const _require = createRequire(import.meta.url); --const yaml = _require('js-yaml') as typeof import('js-yaml'); -- --const DEFAULT_LOGGER: AutoSyncLogger = { -- info: (message) => process.stderr.write(`${message}\n`), -- warn: (message) => process.stderr.write(`${message}\n`), -- error: (message) => process.stderr.write(`${message}\n`), --}; -- --const DEFAULT_DEPS: AutoSyncRunDeps = { -- cloneOrPull, -- getCurrentBranch: async (repoPath, timeoutMs) => { -- const branch = (await runGit(['branch', '--show-current'], repoPath, { timeoutMs })).trim(); -- return branch || undefined; -- }, -- getCurrentCommit: async (repoPath, timeoutMs) => -- (await runGit(['rev-parse', 'HEAD'], repoPath, { timeoutMs })).trim(), -- runAnalysis: runAutoSyncAnalysis, -- registerRepo, -- resolveBranchPlacement, -- loadState: loadAutoSyncState, -- saveState: saveAutoSyncState, -- writeCommitInfo: writeProjectCommitInfo, -- addRepoToGroup, -- syncGroupByName, -- resolveCloneRoot: resolveConfiguredCloneRoot, -- getAvailableMemoryGB: () => Math.floor(process.availableMemory?.() ?? 0) / 1024 / 1024 / 1024, --}; -- --export async function runAutoSyncOnce( -- config: AutoSyncConfig, -- options: { -- deps?: Partial; -- logger?: AutoSyncLogger; -- now?: () => Date; -- signal?: AbortSignal; -- onAnalysisCancellationRequested?: () => void; -- } = {}, --): Promise { -- const deps = { ...DEFAULT_DEPS, ...options.deps }; -- const logger = options.logger ?? DEFAULT_LOGGER; -- const now = options.now ?? (() => new Date()); -- throwIfAborted(options.signal); -- const state = await deps.loadState(); -- throwIfAborted(options.signal); -- const groupsToSync = new Set(); -- const groupStateKeys = new Map(); -- const result: AutoSyncRunResult = { synced: 0, analyzed: 0, skippedAnalysis: 0, failed: 0 }; -- const commitInfoEntries: ProjectCommitInfoEntry[] = []; -- const actualConcurrency = resolveActualConcurrency( -- config.maxConcurrency, -- deps.getAvailableMemoryGB(), -- ); -- logger.info( -- `[auto-sync] Starting sync loop with max_concurrency=${actualConcurrency} analyze_failure_threshold=${config.analyzeFailureThreshold}.`, -- ); -- -- const workItems = await buildWorkItems(config, deps); -- // What will actually run at once. One repo means one worker, so the common -- // single-project case still hands that worker the whole machine budget. -- const analysisParallelism = Math.max(1, Math.min(actualConcurrency, workItems.length)); -- const repoResults = await mapWithConcurrency( -- workItems, -- actualConcurrency, -- options.signal, -- async (item) => { -- const lastSyncTime = now().toISOString(); -- try { -- throwIfAborted(options.signal); -- if (!item.cloneRoot || !item.repoName || !item.targetDir) { -- throw new Error(item.error ?? 'Invalid auto-sync work item'); -- } -- const repoName = item.repoName; -- const targetDir = item.targetDir; -- const syncResult = await syncFirstAvailableBranch({ -- item, -- repoName, -- targetDir, -- timeoutMs: config.repoGitTimeoutMs, -- deps, -- logger, -- }); -- throwIfAborted(options.signal); -- if (syncResult.ok === false) { -- logger.error( -- `[auto-sync] Repository sync failed for ${item.remoteUrl}; no configured branch could be pulled: ${syncResult.message}`, -- ); -- return { -- kind: 'failed' as const, -- project: item.project, -- remoteUrl: item.remoteUrl, -- targetDir, -- branch: item.project.branches[0], -- status: syncResult.status, -- analyzeConsecutiveFailures: 0, -- lastSyncTime, -- }; -- } -- -- const currentBranch = syncResult.branch; -- -- const currentCommit = await deps.getCurrentCommit(targetDir, config.repoGitTimeoutMs); -- const stateKey = buildStateKey(targetDir, currentBranch); -- const previous = state[stateKey]; -- let analyzeStatus: AutoSyncAnalyzeStatus = 'skipped'; -- let analyzedCommitId = previous?.analyzedCommitId; -- let analyzeConsecutiveFailures = previous?.analyzeConsecutiveFailures ?? 0; -- let lastAnalyzeError = previous?.lastAnalyzeError; -- const groupSyncPending = previous?.groupSyncPending === true; -- let stats: RepoMeta['stats'] | undefined; -- -- if (previous && previous.codeCommitId !== currentCommit) { -- analyzeConsecutiveFailures = 0; -- lastAnalyzeError = undefined; -- } -- -- if (analyzeConsecutiveFailures >= config.analyzeFailureThreshold) { -- analyzeStatus = 'threshold_skipped'; -- logger.error( -- `[auto-sync] Skip analysis for ${targetDir}; analyze consecutive failures ${analyzeConsecutiveFailures}/${config.analyzeFailureThreshold} reached threshold. Fix the repository or clear auto-sync state before retrying.`, -- ); -- } else if ( -- shouldAnalyzeCommit({ -- currentCommit, -- previousAnalyzedCommit: previous?.analyzedCommitId, -- previousStatus: previous?.lastAnalyzeStatus, -- }) -- ) { -- try { -- const analysis = await deps.runAnalysis( -- targetDir, -- { branch: currentBranch, skipAgentsMd: true, skipSkills: true }, -- config.analyzeTimeoutMs, -- options.signal, -- options.onAnalysisCancellationRequested, -- analysisParallelism, -- ); -- throwIfAborted(options.signal); -- stats = analysis.stats; -- analyzeStatus = 'success'; -- analyzedCommitId = currentCommit; -- analyzeConsecutiveFailures = 0; -- lastAnalyzeError = undefined; -- } catch (err: unknown) { -- if (options.signal?.aborted) throw err; -- analyzeStatus = 'failed'; -- analyzeConsecutiveFailures += 1; -- lastAnalyzeError = shortErrorMessage(err); -- logger.error( -- `[auto-sync] Analysis failed for ${targetDir}; consecutive failures ${analyzeConsecutiveFailures}/${config.analyzeFailureThreshold}: ${lastAnalyzeError}`, -- ); -- } -- } else { -- logger.info(`[auto-sync] Skip analysis for ${targetDir}; commit unchanged.`); -- } -- throwIfAborted(options.signal); -- -- return { -- kind: 'synced' as const, -- project: item.project, -- repoName, -- remoteUrl: item.remoteUrl, -- targetDir, -- branch: currentBranch, -- currentCommit, -- analyzedCommitId, -- analyzeStatus, -- analyzeConsecutiveFailures, -- lastAnalyzeError, -- groupSyncPending, -- stats, -- stateKey, -- lastSyncTime, -- }; -- } catch (err: unknown) { -- if (options.signal?.aborted) throw err; -- logger.error( -- `[auto-sync] Repository sync failed for ${item.remoteUrl}: ${(err as Error).message}`, -- ); -- return { -- kind: 'failed' as const, -- project: item.project, -- remoteUrl: item.remoteUrl, -- targetDir: item.targetDir ?? '', -- status: 'sync_failed' as const, -- lastSyncTime, -- }; -- } -- }, -- ); -- -- for (const repoResult of repoResults) { -- if (repoResult.kind === 'failed') { -- result.failed += 1; -- commitInfoEntries.push({ -- remoteUrl: repoResult.remoteUrl, -- localPath: repoResult.targetDir, -- branch: repoResult.branch, -- status: repoResult.status, -- lastSyncTime: repoResult.lastSyncTime, -- }); -- continue; -- } -- -- result.synced += 1; -- let analyzeStatus = repoResult.analyzeStatus; -- let analyzeConsecutiveFailures = repoResult.analyzeConsecutiveFailures; -- let lastAnalyzeError = repoResult.lastAnalyzeError; -- let analyzedCommitId = repoResult.analyzedCommitId; -- if (analyzeStatus === 'success') { -- const meta: RepoMeta = { -- repoPath: repoResult.targetDir, -- lastCommit: repoResult.currentCommit, -- indexedAt: repoResult.lastSyncTime, -- stats: repoResult.stats!, -- branch: repoResult.branch, -- remoteUrl: repoResult.remoteUrl, -- }; -- try { -- // Reproduce the placement the analyze worker already made. Registering -- // without a branch always takes the primary/flat arm, which relabels a -- // pinned branch entry with whatever this tick happened to sync — visible -- // on the documented branch-fallback path. -- const placement = await deps.resolveBranchPlacement( -- repoResult.targetDir, -- repoResult.branch, -- ); -- await deps.registerRepo(repoResult.targetDir, meta, { -- name: getAutoSyncRepoIdentity(repoResult.remoteUrl), -- // Omitted rather than passed as undefined, so a primary index is -- // registered with the same option shape it had before this branch. -- ...(placement.branch ? { branch: placement.branch } : {}), -- }); -- result.analyzed += 1; -- } catch (err: unknown) { -- analyzeStatus = 'failed'; -- analyzedCommitId = undefined; -- analyzeConsecutiveFailures += 1; -- lastAnalyzeError = `Repository registration failed: ${shortErrorMessage(err)}`; -- result.failed += 1; -- logger.error(`[auto-sync] ${lastAnalyzeError}`); -- } -- } else if (analyzeStatus === 'failed') { -- result.failed += 1; -- } else { -- result.skippedAnalysis += 1; -- } -- -- const stateEntry: AutoSyncCommitStateEntry = { -- codeCommitId: repoResult.currentCommit, -- analyzedCommitId, -- lastAnalyzeStatus: analyzeStatus, -- analyzeConsecutiveFailures, -- lastAnalyzeError, -- groupSyncPending: repoResult.groupSyncPending, -- lastSyncTime: repoResult.lastSyncTime, -- }; -- state[repoResult.stateKey] = stateEntry; -- -- commitInfoEntries.push({ -- remoteUrl: repoResult.remoteUrl, -- localPath: repoResult.targetDir, -- branch: repoResult.branch, -- codeCommitId: repoResult.currentCommit, -- analyzedCommitId, -- status: analyzeStatus, -- analyzeConsecutiveFailures, -- analyzeFailureThreshold: config.analyzeFailureThreshold, -- lastAnalyzeError, -- lastSyncTime: repoResult.lastSyncTime, -- }); -- -- if (repoResult.project.groupName) { -- let groupMembershipOk = false; -- let membershipAdded = false; -- try { -- membershipAdded = await deps.addRepoToGroup( -- repoResult.project, -- getAutoSyncRepoIdentity(repoResult.remoteUrl), -- getAutoSyncRepoIdentity(repoResult.remoteUrl), -- ); -- groupMembershipOk = true; -- } catch (err: unknown) { -- result.failed += 1; -- logger.error( -- `[auto-sync] Group update failed for ${repoResult.project.groupName}: ${(err as Error).message}`, -- ); -- } -- if ( -- groupMembershipOk && -- (analyzeStatus === 'success' || -- (membershipAdded && analyzeStatus === 'skipped') || -- (analyzeStatus === 'skipped' && repoResult.groupSyncPending)) -- ) { -- const groupName = repoResult.project.groupName; -- groupsToSync.add(groupName); -- const keys = groupStateKeys.get(groupName) ?? []; -- keys.push(repoResult.stateKey); -- groupStateKeys.set(groupName, keys); -- } -- } -- } -- -- await deps.saveState(state); -- await deps.writeCommitInfo(commitInfoEntries); -- let groupStateChanged = false; -- for (const groupName of groupsToSync) { -- try { -- await deps.syncGroupByName(groupName); -- for (const stateKey of groupStateKeys.get(groupName) ?? []) { -- if (state[stateKey].groupSyncPending) { -- state[stateKey].groupSyncPending = false; -- groupStateChanged = true; -- } -- } -- } catch (err: unknown) { -- result.failed += 1; -- for (const stateKey of groupStateKeys.get(groupName) ?? []) { -- if (!state[stateKey].groupSyncPending) { -- state[stateKey].groupSyncPending = true; -- groupStateChanged = true; -- } -- } -- logger.error(`[auto-sync] Group sync failed for ${groupName}: ${(err as Error).message}`); -- } -- } -- if (groupStateChanged) await deps.saveState(state); -- return result; --} -- --function shortErrorMessage(err: unknown): string { -- const message = err instanceof Error ? err.message : String(err); -- return message.replace(/\s+/g, ' ').slice(0, 240); --} -- --export function getConfiguredRepoPath( -- project: Pick, -- repoName: string, -- remoteUrl?: string, --): string { -- if (!remoteUrl) return path.resolve(project.localPath, repoName); -- const identity = getAutoSyncRepoIdentity(remoteUrl); -- return path.resolve(project.localPath, ...identity.split('/').slice(0, -1), repoName); --} -- --export async function addRepoToGroup( -- project: Pick, -- groupPath: string, -- registryName = groupPath, --): Promise { -- if (!project.groupName) return false; -- const groupDir = getGroupDir(getDefaultGitnexusDir(), project.groupName); -- const config = await loadGroupConfig(groupDir); -- if (config.repos[groupPath] === registryName) return false; -- if (config.repos[groupPath] !== undefined) { -- throw new Error(`group path ${groupPath} is already mapped to ${config.repos[groupPath]}`); -- } -- config.repos[groupPath] = registryName; -- await writeGroupConfigAtomic(path.join(groupDir, 'group.yaml'), config); -- return true; --} -- --export function getAutoSyncRepoIdentity(remoteUrl: string): string { -- validateAutoSyncRemoteUrl(remoteUrl); -- const [, host, remotePath] = /^git@([^:\s/]+):([^\s]+)$/.exec(remoteUrl.trim())!; -- return `${host.toLowerCase()}/${remotePath.replace(/\.git$/i, '')}`; --} -- --export async function syncGroupByName(groupName: string): Promise { -- const groupDir = getGroupDir(getDefaultGitnexusDir(), groupName); -- const config = await loadGroupConfig(groupDir); -- await syncGroup(config, { groupDir }); --} -- --async function writeGroupConfigAtomic(filePath: string, config: unknown): Promise { -- const tmpPath = `${filePath}.tmp.${process.pid}.${Date.now()}`; -- await fs.writeFile(tmpPath, yaml.dump(config), 'utf-8'); -- await fs.rename(tmpPath, filePath); --} -- --export function resolveActualConcurrency(configured: number, availableMemoryGB: number): number { -- const memoryLimit = Math.max(1, Math.floor(availableMemoryGB / 2)); -- return Math.max(1, Math.min(configured, memoryLimit)); --} -- --async function buildWorkItems( -- config: AutoSyncConfig, -- deps: AutoSyncRunDeps, --): Promise { -- const items: AutoSyncWorkItem[] = []; -- const targetOwners = new Map(); -- for (const project of config.projects) { -- let cloneRoot: AutoSyncWorkItem['cloneRoot']; -- try { -- cloneRoot = await deps.resolveCloneRoot(project.localPath); -- } catch (err: unknown) { -- for (const remoteUrl of project.remoteUrls) { -- items.push({ project, remoteUrl, error: shortErrorMessage(err) }); -- } -- continue; -- } -- for (const remoteUrl of project.remoteUrls) { -- try { -- const repoName = extractRepoNameFromRemoteUrl(remoteUrl); -- const targetDir = getConfiguredRepoPath({ localPath: cloneRoot.root }, repoName, remoteUrl); -- const previous = targetOwners.get(targetDir); -- if (previous !== undefined) { -- throw new Error( -- `Duplicate auto-sync targetDir ${targetDir} for ${previous} and ${remoteUrl}`, -- ); -- } -- targetOwners.set(targetDir, remoteUrl); -- items.push({ project, remoteUrl, cloneRoot, repoName, targetDir }); -- } catch (err: unknown) { -- items.push({ project, remoteUrl, error: shortErrorMessage(err) }); -- } -- } -- } -- return items; --} -- --async function mapWithConcurrency( -- items: T[], -- concurrency: number, -- signal: AbortSignal | undefined, -- worker: (item: T) => Promise, --): Promise { -- const results: R[] = new Array(items.length); -- let nextIndex = 0; -- const runners = Array.from({ length: Math.min(concurrency, items.length) }, async () => { -- while (nextIndex < items.length) { -- throwIfAborted(signal); -- const currentIndex = nextIndex; -- nextIndex += 1; -- results[currentIndex] = await worker(items[currentIndex]); -- throwIfAborted(signal); -- } -- }); -- // Settle every runner before surfacing a failure. Promise.all rejects on the -- // first error while siblings are still inside a clone or waiting on an -- // analyze fork, and the caller treats that rejection as "the run is over" — -- // it releases the watch mutex and exits, orphaning those children. Each -- // runner already refuses new work at the abort check above, so waiting here -- // costs nothing on the cancel path. -- const settlements = await Promise.allSettled(runners); -- const failure = settlements.find((s) => s.status === 'rejected'); -- if (failure) throw (failure as PromiseRejectedResult).reason; -- return results; --} -- --function throwIfAborted(signal: AbortSignal | undefined): void { -- if (signal?.aborted) throw new Error('Auto-sync run cancelled.'); --} -- --interface AutoSyncWorkItem { -- project: AutoSyncProjectConfig; -- remoteUrl: string; -- cloneRoot?: Awaited>; -- repoName?: string; -- targetDir?: string; -- error?: string; --} -- --async function syncFirstAvailableBranch(input: { -- item: AutoSyncWorkItem; -- repoName: string; -- targetDir: string; -- timeoutMs: number; -- deps: AutoSyncRunDeps; -- logger: AutoSyncLogger; --}): Promise< -- | { ok: true; branch: string } -- | { ok: false; status: 'branch_unavailable' | 'sync_timeout'; message: string } --> { -- const failures: string[] = []; -- let sawTimeout = false; -- for (const branch of input.item.project.branches) { -- try { -- await input.deps.cloneOrPull(input.item.remoteUrl, input.targetDir, undefined, { -- allowedCloneRoot: input.item.cloneRoot!.root, -- expectedRepoName: input.repoName, -- quarantineRoot: input.item.cloneRoot!.quarantineRoot, -- allowAutoSyncSsh: true, -- timeoutMs: input.timeoutMs, -- branch, -- overwriteLocalChanges: input.item.project.overwriteLocalChanges, -- }); -- const currentBranch = await input.deps.getCurrentBranch(input.targetDir, input.timeoutMs); -- if (currentBranch === branch) return { ok: true, branch }; -- failures.push(`${branch}: checked out ${currentBranch ?? ''}`); -- input.logger.warn( -- `[auto-sync] Branch ${branch} for ${input.item.remoteUrl} synced but current branch is ${currentBranch ?? ''}; trying next branch.`, -- ); -- } catch (err: unknown) { -- const message = (err as Error).message; -- if (message.includes('timed out')) sawTimeout = true; -- failures.push(`${branch}: ${message}`); -- input.logger.warn( -- `[auto-sync] Branch ${branch} unavailable for ${input.item.remoteUrl}: ${message}`, -- ); -- } -- } -- return { -- ok: false, -- status: sawTimeout ? 'sync_timeout' : 'branch_unavailable', -- message: failures.join('; '), -- }; --} -diff --git a/gitnexus/src/core/auto-sync/starter.ts b/gitnexus/src/core/auto-sync/starter.ts -deleted file mode 100644 -index 624e092ec..000000000 ---- a/gitnexus/src/core/auto-sync/starter.ts -+++ /dev/null -@@ -1,643 +0,0 @@ --import fs from 'node:fs/promises'; --import crypto from 'node:crypto'; --import path from 'node:path'; --import { execFileSync } from 'node:child_process'; --import { acquireFileLock, FileLockBusyError } from '../../storage/file-lock.js'; --import { getGlobalDir } from '../../storage/repo-manager.js'; --import { isProcessAlive, readProcessStartTime } from '../../utils/process-identity.js'; --import { loadAutoSyncConfig } from './config.js'; --import { runAutoSyncOnce } from './runner.js'; --import { getAutoSyncMutexPath, getAutoSyncWatchDir } from './state.js'; -- --export interface AutoSyncStartHandle { -- stop(): Promise; --} -- --export type WatchStatusState = -- | 'running' -- | 'cancelling' -- | 'stopping' -- | 'stopped' -- | 'stale' -- | 'error'; --export type AutoSyncWatchStopResult = 'stopped' | 'not_running' | 'refused' | 'timeout'; -- --export interface WatchStatusRecord { -- state: WatchStatusState; -- pid?: number; -- ownerId?: string; -- configPath?: string; -- message?: string; -- updatedAt: string; --} -- --export interface WatchOwnerRecord { -- pid: number; -- ownerId: string; -- processStartTime: string; -- createdAt: string; --} -- --interface WatchStopRequestRecord { -- pid: number; -- ownerId: string; -- processStartTime: string; -- requestedAt: string; --} -- --const WATCH_STOP_POLL_MS = 250; -- --export interface AutoSyncWatchPaths { -- pidPath: string; -- mutexPath: string; -- ownerPath: string; -- statusPath: string; --} -- --export interface AutoSyncWatchControlDeps { -- isProcessAlive(pid: number): boolean; -- readProcessCommand(pid: number): string | undefined; -- readProcessStartTime(pid: number): string | undefined; -- sleep(ms: number): Promise; --} -- --export function getAutoSyncWatchPaths(gitnexusDir = getGlobalDir()): AutoSyncWatchPaths { -- const watchDir = getAutoSyncWatchDir(gitnexusDir); -- return { -- pidPath: path.join(watchDir, 'watch.pid'), -- mutexPath: getAutoSyncMutexPath(gitnexusDir), -- ownerPath: path.join(watchDir, 'watch.owner.json'), -- statusPath: path.join(watchDir, 'watch.status.json'), -- }; --} -- --export async function startAutoSyncWatch( -- options: { -- setIntervalFn?: typeof setInterval; -- clearIntervalFn?: typeof clearInterval; -- runOnce?: typeof runAutoSyncOnce; -- stderr?: Pick; -- keepAlive?: boolean; -- paths?: AutoSyncWatchPaths; -- deps?: Partial; -- } = {}, --): Promise { -- const stderr = options.stderr ?? process.stderr; -- const paths = options.paths ?? getAutoSyncWatchPaths(); -- const deps = resolveWatchDeps(options.deps); -- const ownerId = crypto.randomUUID(); -- const processStartTime = deps.readProcessStartTime(process.pid); -- if (!processStartTime) { -- stderr.write('[auto-sync] Unable to verify the watch process start time.\n'); -- return null; -- } -- await fs.mkdir(path.dirname(paths.pidPath), { recursive: true }); -- const releaseLock = await acquireWatchLock(paths, deps, stderr, processStartTime); -- if (!releaseLock) return null; -- -- try { -- await writeWatchOwner(paths, { -- pid: process.pid, -- ownerId, -- processStartTime, -- createdAt: new Date().toISOString(), -- }); -- await writeAtomicText(paths.pidPath, `${process.pid}\n`); -- -- const loaded = await loadAutoSyncConfig(); -- if (loaded.ok === false) { -- stderr.write(`${loaded.message}\n`); -- await writeWatchStatus(paths, { -- state: 'error', -- pid: process.pid, -- ownerId, -- message: loaded.message, -- updatedAt: new Date().toISOString(), -- }); -- await cleanupWatchFiles(paths, ownerId, releaseLock); -- return null; -- } -- await writeWatchStatus(paths, { -- state: 'running', -- pid: process.pid, -- ownerId, -- configPath: loaded.config.configPath, -- updatedAt: new Date().toISOString(), -- }); -- -- const runOnce = options.runOnce ?? runAutoSyncOnce; -- const setIntervalFn = options.setIntervalFn ?? setInterval; -- const clearIntervalFn = options.clearIntervalFn ?? clearInterval; -- let activeRun: Promise | undefined; -- let activeAbortController: AbortController | undefined; -- let stopping = false; -- let statusWrite = Promise.resolve(); -- const updateStatus = (state: WatchStatusState, message?: string) => { -- const write = statusWrite.then(() => -- writeWatchStatus(paths, { -- state, -- pid: process.pid, -- ownerId, -- configPath: loaded.config.configPath, -- message, -- updatedAt: new Date().toISOString(), -- }), -- ); -- statusWrite = write.catch(() => {}); -- return write; -- }; -- const reportStatusWriteFailure = (error: unknown) => { -- stderr.write(`[auto-sync] Failed to publish watch status: ${(error as Error).message}\n`); -- }; -- const runSafely = () => { -- if (stopping) return; -- if (activeRun) { -- stderr.write('[auto-sync] Previous run is still active; skipping overlapping run.\n'); -- return; -- } -- const startedAt = new Date(); -- stderr.write(`[auto-sync] Watch loop started at ${startedAt.toISOString()}.\n`); -- const abortController = new AbortController(); -- const run = runOnce(loaded.config, { -- signal: abortController.signal, -- onAnalysisCancellationRequested: () => { -- if (!stopping) { -- void updateStatus( -- 'cancelling', -- 'Analysis cancellation requested; waiting for the worker to reach a safe shutdown point.', -- ).catch(reportStatusWriteFailure); -- } -- }, -- }) -- .then((result) => { -- stderr.write( -- `[auto-sync] Watch loop finished: synced=${result.synced} analyzed=${result.analyzed} skipped=${result.skippedAnalysis} failed=${result.failed}.\n`, -- ); -- }) -- .catch((err: unknown) => { -- stderr.write(`[auto-sync] Scheduled run failed: ${(err as Error).message}\n`); -- stderr.write('[auto-sync] Watch loop finished: failed.\n'); -- }) -- .finally(async () => { -- if (activeRun === run) { -- activeRun = undefined; -- activeAbortController = undefined; -- } -- if (!stopping) { -- await updateStatus('running').catch(reportStatusWriteFailure); -- } -- }); -- activeRun = run; -- activeAbortController = abortController; -- }; -- -- let stopPromise: Promise | undefined; -- const stop = () => -- (stopPromise ??= (async () => { -- stopping = true; -- clearIntervalFn(timer); -- clearIntervalFn(controlTimer); -- activeAbortController?.abort(); -- try { -- await updateStatus('stopping'); -- await activeRun?.catch(() => {}); -- await updateStatus('stopped'); -- } finally { -- await cleanupWatchFiles(paths, ownerId, releaseLock); -- } -- })()); -- const checkStopRequest = async () => { -- const request = await readStopRequest(stopRequestPath(paths, ownerId)); -- if ( -- request?.pid === process.pid && -- request.ownerId === ownerId && -- request.processStartTime === processStartTime -- ) { -- void stop().catch((error: unknown) => { -- stderr.write(`[auto-sync] Failed to stop watch: ${(error as Error).message}\n`); -- }); -- } -- }; -- -- runSafely(); -- const controlTimer = setIntervalFn(() => void checkStopRequest(), WATCH_STOP_POLL_MS); -- const timer = setIntervalFn(runSafely, loaded.config.syncIntervalMinutes * 60_000); -- if (options.keepAlive === false) { -- controlTimer.unref?.(); -- timer.unref?.(); -- } -- return { stop }; -- } catch (error) { -- await cleanupWatchFiles(paths, ownerId, releaseLock).catch(() => {}); -- throw error; -- } --} -- --async function acquireWatchLock( -- paths: AutoSyncWatchPaths, -- deps: AutoSyncWatchControlDeps, -- stderr: Pick, -- processStartTime: string, --): Promise<(() => Promise) | null> { -- try { -- return await acquireFileLock(paths.mutexPath, { -- pid: process.pid, -- processStartTime, -- isProcessAlive: deps.isProcessAlive, -- readProcessStartTime: deps.readProcessStartTime, -- }); -- } catch (err: unknown) { -- if (!(err instanceof FileLockBusyError)) throw err; -- } -- -- const owner = await readOwnerFile(paths.ownerPath); -- if (!owner) { -- stderr.write( -- `[auto-sync] Watch mutex is held but owner metadata is not ready or invalid. Confirm no watch process is running, then remove ${paths.mutexPath}.\n`, -- ); -- return null; -- } -- if (!deps.isProcessAlive(owner.pid)) { -- stderr.write( -- `[auto-sync] Watch mutex remains after owner pid ${owner.pid} exited. Confirm no watch process is running, then remove ${paths.mutexPath}.\n`, -- ); -- return null; -- } -- const reason = getWatchProcessIdentityError(owner, deps); -- if (reason) { -- stderr.write(`[auto-sync] Refusing to trust existing watch pid ${owner.pid}; ${reason}.\n`); -- return null; -- } -- stderr.write(`[auto-sync] Watch is already running with pid ${owner.pid}.\n`); -- return null; --} -- --export async function stopAutoSyncWatch( -- options: { -- paths?: AutoSyncWatchPaths; -- stderr?: Pick; -- deps?: Partial; -- timeoutMs?: number; -- pollMs?: number; -- } = {}, --): Promise { -- const stderr = options.stderr ?? process.stderr; -- const paths = options.paths ?? getAutoSyncWatchPaths(); -- const deps = resolveWatchDeps(options.deps); -- const timeoutMs = options.timeoutMs ?? 10_000; -- const pollMs = options.pollMs ?? 100; -- const pid = await readPid(paths.pidPath); -- if (!pid) { -- const owner = await readOwnerFile(paths.ownerPath); -- if (owner && deps.isProcessAlive(owner.pid)) { -- stderr.write( -- `[auto-sync] Watch appears to be starting with pid ${owner.pid}; pid file is not ready.\n`, -- ); -- return 'refused'; -- } -- if (owner || (await fileExists(paths.mutexPath))) { -- stderr.write( -- `[auto-sync] Watch ownership is stale or incomplete. Confirm no watch process is running, then remove ${paths.mutexPath}.\n`, -- ); -- return 'refused'; -- } -- stderr.write('[auto-sync] Watch is not running.\n'); -- return 'not_running'; -- } -- if (!deps.isProcessAlive(pid)) { -- stderr.write( -- `[auto-sync] Watch pid ${pid} is stale. Confirm no watch process is running, then remove ${paths.mutexPath}.\n`, -- ); -- return 'refused'; -- } -- -- const owner = await readVerifiedWatchOwner(paths, pid, deps); -- if (owner.ok === false) { -- stderr.write(`[auto-sync] Refusing to stop pid ${pid}; ${owner.reason}.\n`); -- return 'refused'; -- } -- -- const currentPid = await readPid(paths.pidPath); -- const currentOwner = await readVerifiedWatchOwner(paths, pid, deps); -- if ( -- currentPid !== pid || -- currentOwner.ok === false || -- currentOwner.owner.ownerId !== owner.owner.ownerId -- ) { -- stderr.write(`[auto-sync] Refusing to stop pid ${pid}; watch ownership changed.\n`); -- return 'refused'; -- } -- -- await writeAtomicText( -- stopRequestPath(paths, owner.owner.ownerId), -- `${JSON.stringify({ -- pid, -- ownerId: owner.owner.ownerId, -- processStartTime: owner.owner.processStartTime, -- requestedAt: new Date().toISOString(), -- } satisfies WatchStopRequestRecord)}\n`, -- ); -- stderr.write(`[auto-sync] Stop requested for watch pid ${pid}.\n`); -- const stopped = await waitForProcessExit(pid, { -- deps, -- timeoutMs, -- pollMs, -- processStartTime: owner.owner.processStartTime, -- }); -- if (!stopped) { -- stderr.write(`[auto-sync] Watch pid ${pid} did not exit within ${timeoutMs}ms.\n`); -- return 'timeout'; -- } -- return 'stopped'; --} -- --export async function readAutoSyncWatchStatus( -- paths = getAutoSyncWatchPaths(), -- deps: Partial = {}, --): Promise { -- const resolvedDeps = resolveWatchDeps(deps); -- const pid = await readPid(paths.pidPath); -- const stored = await readStatusFile(paths.statusPath); -- const updatedAt = stored?.updatedAt ?? new Date().toISOString(); -- if (pid && !resolvedDeps.isProcessAlive(pid)) { -- return { -- ...stored, -- state: 'stale', -- pid, -- message: 'pid file exists but process is not running', -- updatedAt, -- }; -- } -- if (pid) { -- const owner = await readVerifiedWatchOwner(paths, pid, resolvedDeps); -- if (owner.ok === false) { -- return { -- ...stored, -- state: 'error', -- pid, -- message: owner.reason, -- updatedAt, -- }; -- } -- if (stored?.state === 'error') { -- return { -- ...stored, -- pid, -- ownerId: owner.owner.ownerId, -- updatedAt, -- }; -- } -- return { -- ...stored, -- state: -- stored?.state === 'cancelling' || stored?.state === 'stopping' ? stored.state : 'running', -- pid, -- ownerId: owner.owner.ownerId, -- updatedAt, -- }; -- } -- return stored ?? { state: 'stopped', updatedAt }; --} -- --function isSafeWatchOwnerId(ownerId: string): boolean { -- return ( -- ownerId === path.basename(ownerId) && -- !ownerId.includes('..') && -- !ownerId.includes('/') && -- !ownerId.includes('\\') -- ); --} -- --async function readOwnerFile(ownerPath: string): Promise { -- try { -- const raw = await fs.readFile(ownerPath, 'utf-8'); -- const parsed = JSON.parse(raw) as WatchOwnerRecord; -- if ( -- parsed && -- typeof parsed === 'object' && -- Number.isInteger(parsed.pid) && -- parsed.pid > 0 && -- typeof parsed.ownerId === 'string' && -- parsed.ownerId && -- isSafeWatchOwnerId(parsed.ownerId) && -- typeof parsed.processStartTime === 'string' && -- parsed.processStartTime -- ) { -- return parsed; -- } -- return undefined; -- } catch (err: unknown) { -- if ((err as NodeJS.ErrnoException).code === 'ENOENT') return undefined; -- return undefined; -- } --} -- --async function readVerifiedWatchOwner( -- paths: AutoSyncWatchPaths, -- pid: number, -- deps: AutoSyncWatchControlDeps, --): Promise<{ ok: true; owner: WatchOwnerRecord } | { ok: false; reason: string }> { -- const [status, owner] = await Promise.all([ -- readStatusFile(paths.statusPath), -- readOwnerFile(paths.ownerPath), -- ]); -- if (!owner) return { ok: false, reason: 'watch owner is missing or invalid' }; -- if (!status) return { ok: false, reason: 'watch status is missing or invalid' }; -- if (owner.pid !== pid) return { ok: false, reason: 'watch owner pid does not match pid file' }; -- if (status.pid !== pid) return { ok: false, reason: 'watch status pid does not match pid file' }; -- if (!status.ownerId || status.ownerId !== owner.ownerId) { -- return { ok: false, reason: 'watch status owner does not match watch owner' }; -- } -- const identityError = getWatchProcessIdentityError(owner, deps); -- if (identityError) return { ok: false, reason: identityError }; -- return { ok: true, owner }; --} -- --function getWatchProcessIdentityError( -- owner: WatchOwnerRecord, -- deps: AutoSyncWatchControlDeps, --): string | undefined { -- const processStartTime = deps.readProcessStartTime(owner.pid); -- if (!processStartTime) return 'unable to verify process start time'; -- if (processStartTime !== owner.processStartTime) return 'pid belongs to a different process'; -- const command = deps.readProcessCommand(owner.pid); -- if (!command) return 'unable to verify process command'; -- if ( -- !/(?:^|\s)(?:watch|auto-sync)(?:\s|$)/.test(command) || -- !/(?:gitnexus|[\\/]cli[\\/]index\.(?:ts|[cm]?js))/.test(command) -- ) { -- return 'pid command is not a GitNexus auto-sync process'; -- } -- return undefined; --} -- --async function waitForProcessExit( -- pid: number, -- options: { -- deps: AutoSyncWatchControlDeps; -- timeoutMs: number; -- pollMs: number; -- processStartTime?: string; -- }, --): Promise { -- // A bare liveness poll cannot tell "still running" from "exited, and the OS -- // handed the pid to something else" — so a reused pid would keep us waiting -- // on an unrelated process and then report the watch stopped once THAT exits. -- // The start time identifies the process behind the number. -- const isOriginalProcessAlive = () => { -- if (!options.deps.isProcessAlive(pid)) return false; -- if (!options.processStartTime) return true; -- const startTime = options.deps.readProcessStartTime(pid); -- return startTime === undefined || startTime === options.processStartTime; -- }; -- const deadline = Date.now() + options.timeoutMs; -- while (Date.now() < deadline) { -- if (!isOriginalProcessAlive()) return true; -- await options.deps.sleep(options.pollMs); -- } -- return !isOriginalProcessAlive(); --} -- --async function readPid(pidPath: string): Promise { -- try { -- const raw = await fs.readFile(pidPath, 'utf-8'); -- const pid = Number(raw.trim()); -- return Number.isInteger(pid) && pid > 0 ? pid : undefined; -- } catch (err: unknown) { -- if ((err as NodeJS.ErrnoException).code === 'ENOENT') return undefined; -- throw err; -- } --} -- --async function readStatusFile(statusPath: string): Promise { -- try { -- const parsed = JSON.parse(await fs.readFile(statusPath, 'utf-8')) as WatchStatusRecord; -- return parsed && typeof parsed === 'object' ? parsed : undefined; -- } catch (err: unknown) { -- if ((err as NodeJS.ErrnoException).code === 'ENOENT') return undefined; -- return { -- state: 'error', -- message: `unable to read status file: ${(err as Error).message}`, -- updatedAt: new Date().toISOString(), -- }; -- } --} -- --function stopRequestPath(paths: AutoSyncWatchPaths, ownerId: string): string { -- if (!isSafeWatchOwnerId(ownerId)) { -- throw new Error('watch ownerId is not a safe filename component'); -- } -- return path.join(path.dirname(paths.pidPath), `watch.stop.${ownerId}.json`); --} -- --async function readStopRequest(filePath: string): Promise { -- try { -- const parsed = JSON.parse(await fs.readFile(filePath, 'utf-8')) as WatchStopRequestRecord; -- if ( -- parsed && -- typeof parsed === 'object' && -- Number.isInteger(parsed.pid) && -- parsed.pid > 0 && -- typeof parsed.ownerId === 'string' && -- parsed.ownerId && -- typeof parsed.processStartTime === 'string' && -- parsed.processStartTime && -- typeof parsed.requestedAt === 'string' && -- parsed.requestedAt -- ) { -- return parsed; -- } -- } catch (error: unknown) { -- if ((error as NodeJS.ErrnoException).code !== 'ENOENT') return undefined; -- } -- return undefined; --} -- --async function writeWatchStatus( -- paths: AutoSyncWatchPaths, -- record: WatchStatusRecord, --): Promise { -- await fs.mkdir(path.dirname(paths.statusPath), { recursive: true }); -- const tmpPath = `${paths.statusPath}.tmp.${process.pid}.${Date.now()}`; -- await fs.writeFile(tmpPath, `${JSON.stringify(record, null, 2)}\n`, 'utf-8'); -- await fs.rename(tmpPath, paths.statusPath); --} -- --async function writeWatchOwner(paths: AutoSyncWatchPaths, record: WatchOwnerRecord): Promise { -- await writeAtomicText(paths.ownerPath, `${JSON.stringify(record, null, 2)}\n`); --} -- --async function cleanupWatchFiles( -- paths: AutoSyncWatchPaths, -- ownerId: string, -- releaseLock: () => Promise, --): Promise { -- try { -- const owner = await readOwnerFile(paths.ownerPath); -- if (owner?.ownerId === ownerId) { -- if ((await readPid(paths.pidPath)) === owner.pid) await removeIfExists(paths.pidPath); -- if ((await readOwnerFile(paths.ownerPath))?.ownerId === ownerId) { -- await removeIfExists(paths.ownerPath); -- } -- await removeIfExists(stopRequestPath(paths, ownerId)); -- } -- } finally { -- await releaseLock(); -- } --} -- --async function writeAtomicText(filePath: string, content: string): Promise { -- await fs.mkdir(path.dirname(filePath), { recursive: true }); -- const tmpPath = `${filePath}.tmp.${process.pid}.${Date.now()}`; -- await fs.writeFile(tmpPath, content, 'utf-8'); -- await fs.rename(tmpPath, filePath); --} -- --async function removeIfExists(filePath: string): Promise { -- await fs.rm(filePath, { force: true }); --} -- --async function fileExists(filePath: string): Promise { -- return fs.access(filePath).then( -- () => true, -- () => false, -- ); --} -- --function resolveWatchDeps(deps: Partial = {}): AutoSyncWatchControlDeps { -- return { -- isProcessAlive: deps.isProcessAlive ?? isProcessAlive, -- readProcessCommand: -- deps.readProcessCommand ?? -- ((pid) => { -- try { -- const command = -- process.platform === 'win32' -- ? execFileSync( -- 'powershell.exe', -- [ -- '-NoProfile', -- '-NonInteractive', -- '-Command', -- `(Get-CimInstance Win32_Process -Filter \"ProcessId = ${pid}\").CommandLine`, -- ], -- { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }, -- ).trim() -- : execFileSync('ps', ['-p', String(pid), '-o', 'command='], { -- encoding: 'utf-8', -- stdio: ['ignore', 'pipe', 'ignore'], -- }).trim(); -- return command || undefined; -- } catch { -- return undefined; -- } -- }), -- readProcessStartTime: deps.readProcessStartTime ?? readProcessStartTime, -- sleep: -- deps.sleep ?? -- ((ms) => -- new Promise((resolve) => { -- setTimeout(resolve, ms); -- })), -- }; --} -diff --git a/gitnexus/src/core/auto-sync/state.ts b/gitnexus/src/core/auto-sync/state.ts -deleted file mode 100644 -index 01f3ed2f5..000000000 ---- a/gitnexus/src/core/auto-sync/state.ts -+++ /dev/null -@@ -1,173 +0,0 @@ --import fs from 'node:fs/promises'; --import path from 'node:path'; --import { acquireFileLock, FileLockBusyError } from '../../storage/file-lock.js'; --import { getGlobalDir } from '../../storage/repo-manager.js'; -- --export type AutoSyncAnalyzeStatus = 'success' | 'failed' | 'skipped' | 'threshold_skipped'; -- --export interface AutoSyncCommitStateEntry { -- codeCommitId: string; -- analyzedCommitId?: string; -- lastAnalyzeStatus?: AutoSyncAnalyzeStatus; -- analyzeConsecutiveFailures?: number; -- lastAnalyzeError?: string; -- groupSyncPending?: boolean; -- lastSyncTime: string; --} -- --export type AutoSyncCommitState = Record; -- --export function getAutoSyncWatchDir(gitnexusDir = getGlobalDir()): string { -- return path.join(gitnexusDir, 'watch'); --} -- --export function getAutoSyncMutexPath(gitnexusDir = getGlobalDir()): string { -- return path.join(getAutoSyncWatchDir(gitnexusDir), 'watch.mutex'); --} -- --export function getAutoSyncStatePath(gitnexusDir = getGlobalDir()): string { -- return path.join(getAutoSyncWatchDir(gitnexusDir), 'auto-sync-state.json'); --} -- --export function getProjectCommitInfoPath(gitnexusDir = getGlobalDir()): string { -- return path.join(getAutoSyncWatchDir(gitnexusDir), 'project_commit_info.txt'); --} -- --export async function resetAutoSyncState(gitnexusDir = getGlobalDir()): Promise { -- let releaseLock: () => Promise; -- try { -- releaseLock = await acquireFileLock(getAutoSyncMutexPath(gitnexusDir)); -- } catch (error) { -- if (error instanceof FileLockBusyError) return false; -- throw error; -- } -- -- try { -- await Promise.all([ -- fs.rm(getAutoSyncStatePath(gitnexusDir), { force: true }), -- fs.rm(getProjectCommitInfoPath(gitnexusDir), { force: true }), -- ]); -- return true; -- } finally { -- await releaseLock(); -- } --} -- --export function buildStateKey(repoPath: string, branch: string): string { -- return `${path.resolve(repoPath)}|${branch}`; --} -- --export function shouldAnalyzeCommit(input: { -- currentCommit: string; -- previousAnalyzedCommit?: string; -- previousStatus?: AutoSyncAnalyzeStatus; --}): boolean { -- if (!input.currentCommit) return false; -- if (input.previousStatus === 'failed') return true; -- return input.currentCommit !== input.previousAnalyzedCommit; --} -- --export async function loadAutoSyncState( -- statePath = getAutoSyncStatePath(), --): Promise { -- try { -- const raw = await fs.readFile(statePath, 'utf-8'); -- const parsed = JSON.parse(raw); -- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; -- return Object.fromEntries( -- Object.entries(parsed).filter((entry): entry is [string, AutoSyncCommitStateEntry] => -- isAutoSyncCommitStateEntry(entry[1]), -- ), -- ); -- } catch (err: unknown) { -- if ((err as NodeJS.ErrnoException).code === 'ENOENT') return {}; -- // Corrupt JSON is genuinely unrecoverable, so rebuilding is the only move. -- // An unreadable file (EACCES, EIO, EISDIR) is different: the state is -- // probably intact, and returning {} here would make the tick overwrite it, -- // losing every repo's analyzed commit and failure count. -- if (!(err instanceof SyntaxError)) throw err; -- process.stderr.write( -- `[auto-sync] Ignoring corrupt state file: ${statePath}. State will be rebuilt.\n`, -- ); -- return {}; -- } --} -- --function isAutoSyncCommitStateEntry(value: unknown): value is AutoSyncCommitStateEntry { -- if (!value || typeof value !== 'object' || Array.isArray(value)) return false; -- const entry = value as Record; -- return ( -- typeof entry.codeCommitId === 'string' && -- typeof entry.lastSyncTime === 'string' && -- (entry.analyzedCommitId === undefined || typeof entry.analyzedCommitId === 'string') && -- (entry.lastAnalyzeStatus === undefined || -- entry.lastAnalyzeStatus === 'success' || -- entry.lastAnalyzeStatus === 'failed' || -- entry.lastAnalyzeStatus === 'skipped' || -- entry.lastAnalyzeStatus === 'threshold_skipped') && -- (entry.analyzeConsecutiveFailures === undefined || -- (typeof entry.analyzeConsecutiveFailures === 'number' && -- Number.isInteger(entry.analyzeConsecutiveFailures) && -- entry.analyzeConsecutiveFailures >= 0)) && -- (entry.lastAnalyzeError === undefined || typeof entry.lastAnalyzeError === 'string') && -- (entry.groupSyncPending === undefined || typeof entry.groupSyncPending === 'boolean') -- ); --} -- --export async function saveAutoSyncState( -- state: AutoSyncCommitState, -- statePath = getAutoSyncStatePath(), --): Promise { -- await fs.mkdir(path.dirname(statePath), { recursive: true }); -- const tmpPath = `${statePath}.tmp.${process.pid}.${Date.now()}`; -- await fs.writeFile(tmpPath, `${JSON.stringify(state, null, 2)}\n`, 'utf-8'); -- await fs.rename(tmpPath, statePath); --} -- --export async function writeProjectCommitInfo( -- entries: ProjectCommitInfoEntry[], -- infoPath = getProjectCommitInfoPath(), --): Promise { -- await fs.mkdir(path.dirname(infoPath), { recursive: true }); -- const lines = [ -- '# GitNexus auto-sync project commit info', -- `updated_at: ${new Date().toISOString()}`, -- '', -- ...entries.flatMap((entry) => [ -- `remote: ${entry.remoteUrl}`, -- `local_path: ${entry.localPath}`, -- `branch: ${entry.branch ?? ''}`, -- `code_commit: ${entry.codeCommitId ?? ''}`, -- `analyzed_commit: ${entry.analyzedCommitId ?? ''}`, -- `status: ${entry.status}`, -- `analyze_consecutive_failures: ${entry.analyzeConsecutiveFailures ?? 0}`, -- ...(entry.analyzeFailureThreshold === undefined -- ? [] -- : [`analyze_failure_threshold: ${entry.analyzeFailureThreshold}`]), -- ...(entry.lastAnalyzeError ? [`last_analyze_error: ${entry.lastAnalyzeError}`] : []), -- `last_sync_time: ${entry.lastSyncTime}`, -- '', -- ]), -- ]; -- const tmpPath = `${infoPath}.tmp.${process.pid}.${Date.now()}`; -- await fs.writeFile(tmpPath, `${lines.join('\n')}\n`, 'utf-8'); -- await fs.rename(tmpPath, infoPath); --} -- --export interface ProjectCommitInfoEntry { -- remoteUrl: string; -- localPath: string; -- branch?: string; -- codeCommitId?: string; -- analyzedCommitId?: string; -- status: -- | AutoSyncAnalyzeStatus -- | 'sync_failed' -- | 'branch_skipped' -- | 'branch_unavailable' -- | 'sync_timeout'; -- analyzeConsecutiveFailures?: number; -- analyzeFailureThreshold?: number; -- lastAnalyzeError?: string; -- lastSyncTime: string; --} -diff --git a/gitnexus/src/core/group/extractors/http-patterns/node.ts b/gitnexus/src/core/group/extractors/http-patterns/node.ts -index 7a198f595..f45c6df7a 100644 ---- a/gitnexus/src/core/group/extractors/http-patterns/node.ts -+++ b/gitnexus/src/core/group/extractors/http-patterns/node.ts -@@ -152,6 +152,24 @@ const AXIOS_OBJECT_SPEC: PatternSpec> = { - `, - }; - -+// ─── Consumer: wrapped client X.request({ url, method }) ──────────── -+// Enterprise wrapper shape: an axios instance (or anything request-like) -+// re-exported under a local name — `httpClient.request({ url, method })` -+// from `@winex-plugin/win-request`, `$http.request(...)`, `api.request(...)`. -+// The member property is `request` (not an HTTP verb), so this cannot -+// collide with the Express provider pattern (`router.get`) or the axios -+// member form (`axios.get`). Option keys are resolved programmatically, -+// same as the jQuery ajax / axios object forms. -+const REQUEST_OBJECT_SPEC: PatternSpec> = { -+ meta: {}, -+ query: ` -+ (call_expression -+ function: (member_expression -+ property: (property_identifier) @fn (#eq? @fn "request")) -+ arguments: (arguments (object) @options)) -+ `, -+}; -+ - interface NodePatternBundle { - express: CompiledPatterns>; - fetchNoOptions: CompiledPatterns>; -@@ -160,6 +178,7 @@ interface NodePatternBundle { - jqueryShorthand: CompiledPatterns>; - jqueryAjax: CompiledPatterns>; - axiosObject: CompiledPatterns>; -+ requestObject: CompiledPatterns>; - } - - function compileBundle(language: unknown, name: string): NodePatternBundle { -@@ -177,6 +196,7 @@ function compileBundle(language: unknown, name: string): NodePatternBundle { - jqueryShorthand: mk(JQUERY_SHORTHAND_SPEC, 'jquery-shorthand'), - jqueryAjax: mk(JQUERY_AJAX_SPEC, 'jquery-ajax'), - axiosObject: mk(AXIOS_OBJECT_SPEC, 'axios-object'), -+ requestObject: mk(REQUEST_OBJECT_SPEC, 'request-object'), - }; - } - -@@ -206,6 +226,93 @@ function readStringProp(objectNode: Parser.SyntaxNode, keyNames: readonly string - return null; - } - -+/** -+ * Reduce a wrapped-client `url` value to a routable path for contract -+ * matching. Enterprise wrappers commonly interpolate a service-prefix -+ * variable into the template literal — `` `${client}/api/v1/orders` `` — -+ * where the leading `${...}` is a gateway/host binding, not part of the -+ * route (consumer-path normalization alone would keep it as a leading -+ * `{param}` segment and never match a provider). Split the template on -+ * its interpolations and keep the longest literal segment that starts -+ * with `/`; plain string urls pass through when they look like an -+ * absolute path. Returns null for values that do not reduce to one -+ * (fully-qualified hosts, bare variable names, relative fragments). -+ */ -+function extractWrappedRequestPath(rawUrl: string): string | null { -+ if (!rawUrl.includes('${')) { -+ return rawUrl.startsWith('/') ? rawUrl : null; -+ } -+ let best = ''; -+ for (const segment of rawUrl.split(/\$\{[^}]*\}/)) { -+ if (segment.startsWith('/') && segment.length > 1 && segment.length > best.length) { -+ best = segment; -+ } -+ } -+ return best || null; -+} -+ -+/** -+ * For a standalone `decorator` node (child of class_body / program), -+ * find the related `class_declaration` node that it decorates. In -+ * tree-sitter-typescript the decorator is placed before the class -+ * declaration as a sibling (when decorating a class) or inside the -+ * class_body before a method_definition (when decorating a method); -+ * we walk the parent chain until we find the enclosing class. -+ */ -+function findDecoratedClass(decoratorNode: Parser.SyntaxNode): Parser.SyntaxNode | null { -+ const parent = decoratorNode.parent; -+ if (!parent) return null; -+ // Case 1: decorator is a sibling of the class_declaration at program / -+ // export_statement level. Walk forward through siblings until we find -+ // the class_declaration this decorator belongs to. -+ for (let i = 0; i < parent.namedChildCount; i++) { -+ const child = parent.namedChild(i); -+ if (child && child.id === decoratorNode.id) { -+ for (let j = i + 1; j < parent.namedChildCount; j++) { -+ const next = parent.namedChild(j); -+ if (!next) continue; -+ if (next.type === 'decorator') continue; // adjacent decorators stack -+ if (next.type === 'class_declaration') return next; -+ if (next.type === 'export_statement') { -+ // `export class Foo { ... }` wraps the declaration. -+ for (let k = 0; k < next.namedChildCount; k++) { -+ const inner = next.namedChild(k); -+ if (inner?.type === 'class_declaration') return inner; -+ } -+ } -+ break; -+ } -+ break; -+ } -+ } -+ // Case 2: decorator is inside a class_body (decorating a method) — -+ // walk up to the enclosing class_declaration. -+ return findEnclosingClass(decoratorNode); -+} -+ -+/** -+ * For a method-level decorator node (child of class_body before a -+ * method_definition), find the method_definition it decorates. -+ */ -+function findDecoratedMethod(decoratorNode: Parser.SyntaxNode): Parser.SyntaxNode | null { -+ const parent = decoratorNode.parent; -+ if (!parent || parent.type !== 'class_body') return null; -+ for (let i = 0; i < parent.namedChildCount; i++) { -+ const child = parent.namedChild(i); -+ if (child && child.id === decoratorNode.id) { -+ for (let j = i + 1; j < parent.namedChildCount; j++) { -+ const next = parent.namedChild(j); -+ if (!next) continue; -+ if (next.type === 'decorator') continue; -+ if (next.type === 'method_definition') return next; -+ return null; -+ } -+ return null; -+ } -+ } -+ return null; -+} -+ - /** - * Map each named import's LOCAL binding to its DECLARED export name and source - * module, by walking the file's `import { x as y } from 'm'` statements. Lets -@@ -450,6 +557,18 @@ function resolveConsumerPath( - return legacyShape || looksLikeHttpPath(literal) ? literal : null; - } - -+/** -+ * Find the nearest enclosing class_declaration for a node, or null. -+ */ -+function findEnclosingClass(node: Parser.SyntaxNode): Parser.SyntaxNode | null { -+ let cur: Parser.SyntaxNode | null = node.parent; -+ while (cur) { -+ if (cur.type === 'class_declaration') return cur; -+ cur = cur.parent; -+ } -+ return null; -+} -+ - function scanBundle( - bundle: NodePatternBundle, - tree: Parser.Tree, -@@ -685,6 +804,33 @@ function scanBundle( - }); - } - -+ // Consumer: wrapped client `X.request({ url, method })` — the shared -+ // enterprise axios-instance shape (`httpClient.request` from -+ // win-request and friends). The url goes through -+ // extractWrappedRequestPath first so `` `${client}/api/v1/x` `` emits -+ // `/api/v1/x`, which consumer-path normalization then matches against -+ // a provider for the same route. Skips calls whose `url` does not -+ // reduce to an absolute path. -+ for (const match of runCompiledPatterns(bundle.requestObject, tree)) { -+ const optionsNode = match.captures.options; -+ if (!optionsNode) continue; -+ const rawUrl = readStringProp(optionsNode, ['url']); -+ if (rawUrl === null) continue; -+ const path = extractWrappedRequestPath(rawUrl); -+ if (path === null) continue; -+ const rawMethod = readStringProp(optionsNode, ['method', 'type']); -+ const method = (rawMethod ?? 'GET').toUpperCase(); -+ out.push({ -+ role: 'consumer', -+ framework: 'request', -+ method, -+ path, -+ name: null, -+ line: optionsNode.startPosition.row + 1, -+ confidence: 0.65, -+ }); -+ } -+ - for (const route of scanDataRouteTables(tree)) { - const imported = - route.handlerLocalName === undefined ? undefined : importMap.get(route.handlerLocalName); -diff --git a/gitnexus/src/core/group/extractors/http-route-extractor.ts b/gitnexus/src/core/group/extractors/http-route-extractor.ts -index 5def6a9a9..c0407c6a4 100644 ---- a/gitnexus/src/core/group/extractors/http-route-extractor.ts -+++ b/gitnexus/src/core/group/extractors/http-route-extractor.ts -@@ -297,12 +297,59 @@ export function normalizeHttpPath(p: string): string { - * - strip protocol + host if the URL is absolute - * - numeric segments → `{param}` (so `/api/orders/42` → `/api/orders/{param}`) - */ --function normalizeConsumerPath(url: string): string { -- const templated = url.replace(/\$\{[^}]+\}/g, '{param}').trim(); -+/** -+ * Strip LEADING template interpolations from a consumer url as gateway/host -+ * bindings — the enterprise wrapper shape `` `${serviceClient}/api/v1/x` `` -+ * where `${serviceClient}` selects the gateway service, not a route segment. -+ * This is consumer-path framework semantics (mirrors how an absolute -+ * `https://host/path` url keeps only its path), so it lives here rather than -+ * in any one language plugin: -+ * - `` `${c}/api/x` `` → `/api/x` (clean prefix; `${c}${d}/api/x` → `/api/x`) -+ * - `` `${c}/api/x/${id}` ``→ `/api/x/${id}` (mid/tail interpolations are left for the `{param}` pass) -+ * Returns null when the stripped remainder does not start with `/` — the url -+ * then does not reduce to a routable path and the consumer is dropped, the -+ * SAME rejection the plugins give static relative urls at scan time: -+ * - `` `${c}api/x` `` — the remainder is a relative fragment; whether it -+ * reads as a path depends on unverifiable runtime state (the binding -+ * happening to end in `/`), so keeping it could match an unrelated -+ * provider — same reasoning as the static `api/x` rejection; -+ * - `` `${scheme}://${host}/api/x` `` and `` `${c}api${d}/x` `` — host -+ * fragments and path cannot be told apart (pre-strip the first produced a -+ * dead `/{param}://{param}/api/x` contract that never matched, so dropping -+ * loses nothing). -+ * -+ * The `?` in a query string cannot leak into the brace matching: `${...}` -+ * spans are matched by braces here (before any `{param}` replacement), and -+ * `normalizeHttpPath` splits on `?` only after the whole `${...}` span — -+ * including any `?` inside it — has been collapsed to `{param}`. So -+ * `` `${c}/api/x?id=${id}` `` reduces to `/api/x` on both orderings. -+ */ -+function stripLeadingTemplatePrefix(url: string): string | null { -+ if (!url.startsWith('${')) return url; -+ const rest = url.replace(/^(?:\$\{[^}]*\})+/, ''); -+ // A remainder without the leading `/` is a relative fragment (host join -+ // slash living inside the binding, or a scheme://… shape) — not provably a -+ // routable path, so it is dropped rather than guessed at. -+ return rest.startsWith('/') ? rest : null; -+} -+ -+function normalizeConsumerPath(url: string): string | null { -+ const stripped = stripLeadingTemplatePrefix(url.trim()); -+ if (stripped === null) return null; -+ const templated = stripped.replace(/\$\{[^}]+\}/g, '{param}').trim(); - let pathOnly = templated; - if (/^https?:\/\//i.test(templated)) { - try { -- pathOnly = new URL(templated).pathname; -+ // Restore the braces of our OWN `{param}` markers: the templating pass -+ // above already collapsed every `${...}` span, and the WHATWG URL -+ // parser percent-encodes braces in the pathname (`{param}` → -+ // `%7Bparam%7D`), which would hide them from normalizeHttpPath's -+ // `{...}` fold below — the contract then reads as a literal segment -+ // that can never match its `/{param}` provider. Only the brace -+ // encodings are restored (not a full decodeURIComponent) so genuine -+ // `%XX` sequences in the path survive untouched, and a malformed -+ // escape like `/api/100%off` cannot throw here. -+ pathOnly = new URL(templated).pathname.replace(/%7b/gi, '{').replace(/%7d/gi, '}'); - } catch { - pathOnly = templated.replace(/^https?:\/\/[^/]+/i, ''); - } -@@ -999,6 +1046,12 @@ export class HttpRouteExtractor implements ContractExtractor { - for (const d of detections) { - if (d.role !== 'consumer') continue; - const pathNorm = normalizeConsumerPath(d.path); -+ // A consumer url that cannot be reduced to a routable path (e.g. a -+ // leading template binding that is neither a clean prefix nor the -+ // unique `/`-bearing literal run) is dropped here rather than emitted -+ // as a never-matching contract — same treatment the plugins give -+ // static relative urls at scan time. -+ if (pathNorm === null) continue; - // Resolve the function CONTAINING the fetch/axios call so the consumer - // contract carries a real symbolUid (was always '' — the gap that left - // cross-repo trace/impact unable to traverse HTTP links). -diff --git a/gitnexus/src/core/group/group-lock.ts b/gitnexus/src/core/group/group-lock.ts -index cdaacb283..7d25753af 100644 ---- a/gitnexus/src/core/group/group-lock.ts -+++ b/gitnexus/src/core/group/group-lock.ts -@@ -21,11 +21,12 @@ - * else claims: group directories live under `~/.gitnexus/groups/` (or - * `$GITNEXUS_HOME`), never under a repo's `.gitnexus[/branches/]`. - * -- * WHY IT FAILS CLOSED, like the registry lock. `withRegistryLock` also -- * refuses to continue unlocked on timeout: a lost registry update can drop a -- * concurrent registration. A group sync still fails closed for additional -- * reasons — it is long, expensive, operator-initiated, and a lost update -- * destroys contracts rather than a registry field. -+ * WHY IT FAILS CLOSED, unlike the registry lock. `withRegistryLock` degrades to -+ * running UNLOCKED on timeout, and that is right for it: it guards a sub-second -+ * JSON read/merge/write on a latency-critical path (`augment` runs on every -+ * editor tool call), and running unlocked is merely the pre-lock status quo. A -+ * group sync is the opposite on every axis — it is long, expensive, operator- -+ * initiated, and its lost update destroys contracts rather than a registry field. - * A sync that cannot be protected must not run at all, and there are three - * distinct ways it can fail to be protected; all three throw - * {@link GroupSyncLockError}: -diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts -index 40ccdb8e0..eadee1ce0 100644 ---- a/gitnexus/src/core/lbug/lbug-adapter.ts -+++ b/gitnexus/src/core/lbug/lbug-adapter.ts -@@ -72,7 +72,6 @@ import { - shadowSidecarRecoveryMessage, - sidecarPreflightDisabled, - } from './sidecar-recovery.js'; --import { isProcessAlive } from '../../utils/process-identity.js'; - - import { logger } from '../logger.js'; - import { -@@ -331,6 +330,20 @@ const INIT_LOCK_RETRY_DELAY_MS = 500; - - const initLockPath = (dbPath: string): string => `${dbPath}.init.lock`; - -+/** -+ * Returns true when the process identified by `pid` is still running. -+ * Uses `process.kill(pid, 0)` which sends signal 0 (a no-op probe) — -+ * it throws ESRCH when the process does not exist. -+ */ -+const isProcessAlive = (pid: number): boolean => { -+ try { -+ process.kill(pid, 0); -+ return true; -+ } catch { -+ return false; -+ } -+}; -+ - /** - * Try to break a stale lock whose owning process has exited. - * Returns `true` if the stale lock was removed (caller should retry acquire). -diff --git a/gitnexus/src/server/analyze-worker-protocol.ts b/gitnexus/src/server/analyze-worker-protocol.ts -index d573f5a5c..81193598e 100644 ---- a/gitnexus/src/server/analyze-worker-protocol.ts -+++ b/gitnexus/src/server/analyze-worker-protocol.ts -@@ -26,20 +26,13 @@ - import type { AnalyzeOptions } from '../core/run-analyze.js'; - import type { AnalyzeResultIpc } from './analyze-worker-ipc.js'; - --/** Parent → child: start one analysis run. */ -+/** Parent → child: the single command that starts an analysis run. */ - export interface StartMessage { - type: 'start'; - repoPath: string; - options: AnalyzeOptions; - } - --/** Parent → child: request safe cancellation at the next JS-visible checkpoint. */ --export interface CancelMessage { -- type: 'cancel'; --} -- --export type ParentMessage = StartMessage | CancelMessage; -- - export interface ProgressMessage { - type: 'progress'; - phase: string; -diff --git a/gitnexus/src/server/analyze-worker.ts b/gitnexus/src/server/analyze-worker.ts -index d41a4b34d..ad4632eb0 100644 ---- a/gitnexus/src/server/analyze-worker.ts -+++ b/gitnexus/src/server/analyze-worker.ts -@@ -6,13 +6,12 @@ - * - * IPC Protocol: - * Parent -> Child: { type: 'start', repoPath: string, options: AnalyzeOptions } -- * Parent -> Child: { type: 'cancel' } - * Child -> Parent: { type: 'progress', phase: string, percent: number, message: string } - * Child -> Parent: { type: 'complete', result: AnalyzeResult } - * Child -> Parent: { type: 'error', message: string } - */ - --import type { ParentMessage, WorkerMessage } from './analyze-worker-protocol.js'; -+import type { StartMessage, WorkerMessage } from './analyze-worker-protocol.js'; - import { runWorkerAnalysis, createTerminalClaim } from './analyze-worker-core.js'; - type BoundedCheckpointBeforeExit = - typeof import('../core/lbug/shutdown-helpers.js').boundedCheckpointBeforeExit; -@@ -63,24 +62,19 @@ process.on('unhandledRejection', (reason: unknown) => { - } - }); - --// IPC cancellation is the cross-platform control path. It only records the --// request while analysis is active; cleanup waits until the analysis promise has --// returned to JS. SIGTERM is retained only for local process shutdown. --let cancellationRequested = false; --let started = false; --function requestWorkerCancellation(source: string): void { -- if (cancellationRequested) return; -- cancellationRequested = true; -+// Handle cancellation / timeout shutdown (analyze-job.ts `cancelJob` sends -+// SIGTERM). Bounded CHECKPOINT-then-exit shared with the CLI SIGINT path (#2264): -+// skip the native close (the LadybugDB destructor can double-free after --pdg -+// writes), but don't block behind the in-flight COPY's connection lock — so a -+// single cancel can't abort or hang the worker. A CHECKPOINT failure is reported -+// to the parent over IPC, not swallowed; the exit always fires. -+process.on('SIGTERM', () => { -+ // Only report the cancellation if the analysis hasn't already reported a -+ // terminal outcome (#2264 P3) — otherwise this would flip an already-complete -+ // job to failed. The cleanup + exit below run regardless. - if (claimTerminal()) { -- send({ type: 'error', message: `Analysis cancelled (${source})` }); -- } -- if (!started) { -- // No analysis has started, so no native work needs a safe-point handshake. -- process.exit(0); -+ send({ type: 'error', message: 'Analysis cancelled (worker received SIGTERM)' }); - } --} -- --function exitAfterCancellation(): void { - if (!boundedCheckpointBeforeExit) { - process.exit(0); - return; -@@ -89,21 +83,16 @@ function exitAfterCancellation(): void { - exitCode: 0, - onFlushError: (err: unknown) => { - const message = -- err instanceof Error ? err.message : 'Worker checkpoint failed during cancellation'; -+ err instanceof Error ? err.message : 'Worker checkpoint failed during SIGTERM'; - send({ type: 'error', message }); - }, - }); --} -- --process.on('SIGTERM', () => requestWorkerCancellation('worker received SIGTERM')); -+}); - --// Listen for parent commands — guarded against re-entry. --process.on('message', async (msg: ParentMessage) => { -- if (msg.type === 'cancel') { -- requestWorkerCancellation('parent requested cancellation'); -- return; -- } -- if (started) return; -+// Listen for start command from parent — guarded against re-entry -+let started = false; -+process.on('message', async (msg: StartMessage) => { -+ if (msg.type !== 'start' || started) return; - started = true; - - try { -@@ -123,9 +112,6 @@ process.on('message', async (msg: ParentMessage) => { - }, - ); - boundedCheckpointBeforeExit = prepared.loaded.shutdownHelpers.boundedCheckpointBeforeExit; -- // A cancel can arrive while the dynamic imports are resolving. Do not begin -- // a new analysis after that request; the finally block performs safe cleanup. -- if (cancellationRequested) return; - // The run → finalize → report contract lives in the side-effect-free - // analyze-worker-core seam (unit-testable without this entry module's - // process.on side effects). It reports exactly one terminal message and -@@ -149,11 +135,9 @@ process.on('message', async (msg: ParentMessage) => { - }); - } - } finally { -- // A cancel must not end the process while runFullAnalysis may still be in -- // native code. This continuation runs only after that promise has settled. -- if (cancellationRequested) exitAfterCancellation(); -- // Normal terminal outcomes still need the existing process exit because -- // LadybugDB stays live. -- else setTimeout(() => process.exit(0), 500); -+ // LadybugDB's native module prevents clean exit — force it (same reason the -+ // CLI uses process.exit(0)). In `finally` so the exit still fires even if the -+ // report above throws on a closed IPC channel (#2264 review P3). -+ setTimeout(() => process.exit(0), 500); - } - }); -diff --git a/gitnexus/src/server/api.ts b/gitnexus/src/server/api.ts -index fc3c1300a..2758391aa 100644 ---- a/gitnexus/src/server/api.ts -+++ b/gitnexus/src/server/api.ts -@@ -58,7 +58,7 @@ import { assertString, BadRequestError, createRouteLimiter } from './validation. - import { parseGrepQuery, GREP_TIME_BUDGET_MS } from './grep-params.js'; - import { runGrepScanInWorker } from './grep-scan.js'; - import { -- extractWebRepoName, -+ extractRepoName, - getCloneDir, - cloneOrPull, - warnIfInsecureAzureConfig, -@@ -1598,7 +1598,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => - try { - // Clone if URL provided - if (repoUrl && !repoLocalPath) { -- const repoName = extractWebRepoName(repoUrl); -+ const repoName = extractRepoName(repoUrl); - targetPath = getCloneDir(repoName); - - jobManager.updateJob(job.id, { -diff --git a/gitnexus/src/server/git-clone.ts b/gitnexus/src/server/git-clone.ts -index 1cd204761..742be319c 100644 ---- a/gitnexus/src/server/git-clone.ts -+++ b/gitnexus/src/server/git-clone.ts -@@ -9,15 +9,9 @@ import { spawn } from 'child_process'; - import path from 'path'; - import fs from 'fs/promises'; - import { isIP } from 'net'; --import os from 'node:os'; - import { logger } from '../core/logger.js'; -+import { parseRepoNameFromUrl, stripUrlCredentials } from '../storage/git.js'; - import { getGlobalDir } from '../storage/repo-manager.js'; --import { sanitizeRepoName, stripUrlCredentials } from '../storage/git.js'; --import { -- assertDirectoryOwnerAndPermissions, -- quarantineAutoSyncPartial, --} from '../core/auto-sync/path-security.js'; --import { validateAutoSyncRemoteUrl } from '../core/auto-sync/config.js'; - - /** - * Root directory for all cloned repositories. Targets must resolve inside this. -@@ -45,16 +39,12 @@ export const REPO_NAME_PATTERN = /^[a-zA-Z0-9._-]+$/; - * clone root via path traversal. - */ - export function extractRepoName(url: string): string { -- let trimmed = url.trim(); -- while (trimmed.endsWith('/')) trimmed = trimmed.slice(0, -1); -- const withoutGit = trimmed.toLowerCase().endsWith('.git') ? trimmed.slice(0, -4) : trimmed; -- const name = withoutGit.split(/[/:]/).filter(Boolean).pop() ?? ''; -+ const name = parseRepoNameFromUrl(url); - if ( - !name || - name === '.' || - name === '..' || - name === 'unknown' || -- name.startsWith('-') || - !REPO_NAME_PATTERN.test(name) - ) { - throw new Error('Could not extract a valid repository name from URL'); -@@ -62,26 +52,6 @@ export function extractRepoName(url: string): string { - return name; - } - --/** -- * Derive a clone directory name for the web `/api/analyze` boundary. -- * -- * The API historically accepted Azure DevOps and similar URLs whose repo -- * segment contains spaces or other directory-unsafe characters by sanitizing -- * the final segment. Keep that compatibility at the web boundary while leaving -- * `extractRepoName()` strict for internal/security-sensitive callers. -- */ --export function extractWebRepoName(url: string): string { -- let trimmed = url.trim(); -- while (trimmed.endsWith('/')) trimmed = trimmed.slice(0, -1); -- const withoutGit = trimmed.toLowerCase().endsWith('.git') ? trimmed.slice(0, -4) : trimmed; -- const rawName = withoutGit.split(/[/:]/).filter(Boolean).pop() ?? ''; -- const safeName = sanitizeRepoName(rawName); -- if (!rawName || safeName === 'unknown') { -- throw new Error('Could not extract a valid repository name from URL'); -- } -- return safeName; --} -- - /** Get the clone target directory for a repo name. */ - export function getCloneDir(repoName: string): string { - // Re-validate at the boundary even though extractRepoName already checked — -@@ -117,10 +87,6 @@ export function validateGitUrl(url: string): void { - throw new Error('Only https:// and http:// git URLs are allowed'); - } - -- if (parsed.search || parsed.hash) { -- throw new Error('Git URLs must not include query strings or fragments'); -- } -- - const host = parsed.hostname.toLowerCase(); - - // Block known dangerous hostnames (cloud metadata services) -@@ -269,26 +235,6 @@ export interface CloneProgress { - message: string; - } - --export interface CloneOrPullOptions { -- token?: string; -- allowedCloneRoot?: string; -- expectedRepoName?: string; -- quarantineRoot?: string; -- allowAutoSyncSsh?: boolean; -- timeoutMs?: number; -- branch?: string; -- overwriteLocalChanges?: boolean; -- runGitForTest?: typeof runGit; --} -- --type RunGitOptions = { -- token?: string; -- url?: string; -- timeoutMs?: number; -- timeoutKillGraceMs?: number; -- spawnForTest?: typeof spawn; --}; -- - /** - * Build the `git clone` argument list for a given URL and target directory. - * -@@ -358,10 +304,6 @@ export function buildCloneArgs(url: string, targetDir: string): string[] { - return ['clone', '--depth', '1', '--', url, targetDir]; - } - --export function buildBranchCloneArgs(url: string, targetDir: string, branch: string): string[] { -- return ['clone', '--depth', '1', '--branch', branch, '--', url, targetDir]; --} -- - /** - * Normalize a git URL into a comparable form. - * -@@ -421,14 +363,27 @@ export function normalizeGitUrlForCompare(url: string): string { - * remote means for its threat model — for cloneOrPull, a missing remote - * on an existing clone is treated as a refuse-to-pull condition. - */ --export async function getRemoteOriginUrl(cwd: string, timeoutMs?: number): Promise { -- try { -- const stdout = await runGit(['config', '--get', 'remote.origin.url'], cwd, { timeoutMs }); -- return stdout.trim() || null; -- } catch (error) { -- if ((error as Error).message.includes('timed out')) throw error; -- return null; -- } -+export function getRemoteOriginUrl(cwd: string): Promise { -+ return new Promise((resolve) => { -+ const proc = spawn('git', ['config', '--get', 'remote.origin.url'], { -+ cwd, -+ stdio: ['ignore', 'pipe', 'pipe'], -+ windowsHide: true, -+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, -+ }); -+ let stdout = ''; -+ proc.stdout.on('data', (chunk: Buffer) => { -+ stdout += chunk; -+ }); -+ proc.on('close', (code) => { -+ if (code === 0 && stdout.trim()) { -+ resolve(stdout.trim()); -+ } else { -+ resolve(null); -+ } -+ }); -+ proc.on('error', () => resolve(null)); -+ }); - } - - /** -@@ -448,9 +403,8 @@ export async function getRemoteOriginUrl(cwd: string, timeoutMs?: number): Promi - export async function assertRemoteMatchesRequestedUrl( - targetDir: string, - requestedUrl: string, -- timeoutMs?: number, - ): Promise { -- const remoteUrl = await getRemoteOriginUrl(targetDir, timeoutMs); -+ const remoteUrl = await getRemoteOriginUrl(targetDir); - if (remoteUrl === null) { - throw new Error(`Existing clone at ${targetDir} has no remote.origin — refusing to pull`); - } -@@ -492,213 +446,51 @@ export async function cloneOrPull( - url: string, - targetDir: string, - onProgress?: (progress: CloneProgress) => void, -- options?: CloneOrPullOptions, -+ options?: { token?: string }, - ): Promise { - // Containment barrier — inline with the canonical path.relative idiom so - // CodeQL recognizes the sanitizer at every following filesystem and - // subprocess sink. The same `safeTarget` is used for every downstream - // path operation — no reassignment that the analyzer could lose track of. - // -- // The lexical check runs before filesystem creation; realpath and symlink -- // checks below run before pull/clone and again after clone completes. -- const cloneRoot = path.resolve(options?.allowedCloneRoot ?? CLONE_ROOT); -- const expectedRepoName = options?.expectedRepoName; -- if (expectedRepoName !== undefined && expectedRepoName !== extractRepoName(url)) { -- throw new Error(`Clone target repo name ${expectedRepoName} does not match requested URL`); -- } -- -+ // Limitation: this is a lexical containment check, not a realpath check. -+ // If an attacker can place a symlink under CLONE_ROOT pointing outside it, -+ // the lexical check passes but the clone lands at the symlink target. That -+ // requires pre-existing local write access to CLONE_ROOT, so the threat -+ // model considers it out of scope; CodeQL js/path-injection accepts the -+ // lexical form. Tracked as a follow-up if defense-in-depth is needed. - const safeTarget = path.resolve(targetDir); -- if (expectedRepoName !== undefined && path.basename(safeTarget) !== expectedRepoName) { -- throw new Error(`Clone target basename must match repository name ${expectedRepoName}`); -- } -- -- const rel = path.relative(cloneRoot, safeTarget); -+ const rel = path.relative(CLONE_ROOT, safeTarget); - if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) { -- throw new Error(`Clone target must be a subdirectory of ${cloneRoot}`); -+ throw new Error(`Clone target must be a subdirectory of ${CLONE_ROOT}`); - } - - // Always validate the requested URL — the prior shape only ran this in - // the code path where the repo was cloned. Now it runs unconditionally, - // preventing SSRF / blocked-host bypasses even when targetDir already exists. -- if (options?.allowAutoSyncSsh) validateAutoSyncRemoteUrl(url); -- else validateGitUrl(url); -- await fs.mkdir(cloneRoot, { recursive: true }); -- if (options?.allowedCloneRoot) { -- await assertDirectoryOwnerAndPermissions(cloneRoot); -- } -- await assertNoSymlinkPath(cloneRoot, safeTarget, Boolean(options?.allowedCloneRoot)); -- await fs.mkdir(path.dirname(safeTarget), { recursive: true }); -- await assertNoSymlinkPath(cloneRoot, safeTarget, Boolean(options?.allowedCloneRoot)); -- await assertPreRealpathContainment(cloneRoot, safeTarget); -+ validateGitUrl(url); - - const exists = await fs.access(path.join(safeTarget, '.git')).then( - () => true, - () => false, - ); - -- const targetExists = await fs.access(safeTarget).then( -- () => true, -- () => false, -- ); -- - if (exists) { -- if (options?.allowedCloneRoot) { -- await assertNoSymlinkPath(cloneRoot, path.join(safeTarget, '.git'), true); -- } -- await assertPostRealpathContainment(cloneRoot, safeTarget); - // Confirm the existing clone is actually the same repository the caller - // requested. Without this check, a pull would silently succeed against - // whatever remote the dir was originally cloned from. -- await assertRemoteMatchesRequestedUrl(safeTarget, url, options?.timeoutMs); -+ await assertRemoteMatchesRequestedUrl(safeTarget, url); - onProgress?.({ phase: 'pulling', message: 'Pulling latest changes...' }); -- const runGitImpl = options?.runGitForTest ?? runGit; -- if (options?.branch) { -- if (!options.overwriteLocalChanges) { -- const status = await runGitImpl(['status', '--porcelain'], safeTarget, { -- token: options?.token, -- url, -- timeoutMs: options?.timeoutMs, -- }); -- if (status.trim()) { -- throw new Error( -- `Refusing to update ${safeTarget}: local changes detected. Set overwrite_local_changes: true to overwrite them.`, -- ); -- } -- } -- await runGitImpl( -- [ -- 'fetch', -- '--depth', -- '1', -- 'origin', -- `refs/heads/${options.branch}:refs/remotes/origin/${options.branch}`, -- ], -- safeTarget, -- { -- token: options?.token, -- url, -- timeoutMs: options?.timeoutMs, -- }, -- ); -- await runGitImpl( -- [ -- 'checkout', -- ...(options.overwriteLocalChanges ? ['--force'] : []), -- '-B', -- options.branch, -- `origin/${options.branch}`, -- ], -- safeTarget, -- { -- token: options?.token, -- url, -- timeoutMs: options?.timeoutMs, -- }, -- ); -- if (options.overwriteLocalChanges) { -- // `checkout --force` rewrites tracked files only, so untracked sources -- // left by an operator or an earlier branch survive and then get indexed -- // as if they were part of the remote commit. Deliberately no `-x`/`-X`: -- // ignored paths must survive, and `-e /.gitnexus` is belt-and-braces -- // because `.git/info/exclude` is skipped on a read-only storage mount -- // and a freshly cloned repo may not have been analyzed yet at all. -- await runGitImpl(['clean', '--force', '-d', '-e', '/.gitnexus'], safeTarget, { -- token: options?.token, -- url, -- timeoutMs: options?.timeoutMs, -- }); -- } -- } else { -- await runGitImpl(['pull', '--ff-only'], safeTarget, { -- token: options?.token, -- url, -- timeoutMs: options?.timeoutMs, -- }); -- } -+ await runGit(['pull', '--ff-only'], safeTarget, { token: options?.token, url }); - } else { -- if (targetExists && (await fs.readdir(safeTarget)).length > 0) { -- throw new Error(`Clone target already exists but is not a git repository: ${safeTarget}`); -- } -+ await fs.mkdir(path.dirname(safeTarget), { recursive: true }); - onProgress?.({ phase: 'cloning', message: `Cloning ${url}...` }); -- try { -- const runGitImpl = options?.runGitForTest ?? runGit; -- const cloneArgs = options?.branch -- ? buildBranchCloneArgs(url, safeTarget, options.branch) -- : buildCloneArgs(url, safeTarget); -- await runGitImpl(cloneArgs, undefined, { -- token: options?.token, -- url, -- timeoutMs: options?.timeoutMs, -- }); -- await assertPostRealpathContainment(cloneRoot, safeTarget); -- } catch (err: unknown) { -- if (options?.quarantineRoot) { -- const partialExists = await fs.access(safeTarget).then( -- () => true, -- () => false, -- ); -- if (partialExists) { -- try { -- await quarantineAutoSyncPartial(safeTarget, options.quarantineRoot); -- } catch (quarantineError) { -- throw new AggregateError( -- [err, quarantineError], -- `Clone failed and partial checkout could not be quarantined: ${safeTarget}`, -- ); -- } -- } -- } -- throw err; -- } -+ await runGit(buildCloneArgs(url, safeTarget), undefined, { token: options?.token, url }); - } - - return safeTarget; - } - --async function assertPreRealpathContainment(root: string, target: string): Promise { -- const realRoot = await fs.realpath(root); -- const realParent = await fs.realpath(path.dirname(target)); -- const parentRel = path.relative(realRoot, realParent); -- if (parentRel.startsWith('..') || path.isAbsolute(parentRel)) { -- throw new Error(`Clone target parent must resolve inside ${root}`); -- } --} -- --async function assertPostRealpathContainment(root: string, target: string): Promise { -- const realRoot = await fs.realpath(root); -- const realTarget = await fs.realpath(target); -- const rel = path.relative(realRoot, realTarget); -- if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) { -- throw new Error(`Clone target must resolve inside ${root}`); -- } --} -- --async function assertNoSymlinkPath( -- root: string, -- target: string, -- verifyOwnership = false, --): Promise { -- const resolvedRoot = path.resolve(root); -- const resolvedTarget = path.resolve(target); -- const relativeTarget = path.relative(resolvedRoot, resolvedTarget); -- if (relativeTarget.startsWith('..') || path.isAbsolute(relativeTarget)) return; -- let current = resolvedRoot; -- for (const segment of relativeTarget.split(path.sep).filter(Boolean)) { -- current = path.join(current, segment); -- let stat; -- try { -- stat = await fs.lstat(current); -- } catch (err: unknown) { -- if ((err as NodeJS.ErrnoException).code === 'ENOENT') break; -- throw err; -- } -- if (stat.isSymbolicLink()) { -- throw new Error(`Refusing symlink in clone target path: ${current}`); -- } -- if (verifyOwnership) await assertDirectoryOwnerAndPermissions(current); -- } --} -- - /** - * Hosts the per-request GitHub PAT may be sent to. Exported so the - * /api/analyze boundary check and this injection-site check share one -@@ -800,10 +592,11 @@ function warnIfCleartextCredential(url?: string): void { - } - - /** -- * Build the spawn env for managed `git` commands. Suppresses credential -- * prompts, disables repository hooks, and injects at most one host-scoped -- * Authorization header via the `GIT_CONFIG_*` env protocol (git ≥2.31). -- * Managed settings append after any existing GIT_CONFIG_COUNT. Exported for -+ * Build the spawn env for `git`. Suppresses credential prompts and, when a -+ * credential resolves (see resolveGitCredential), injects a single -+ * host-scoped Authorization header via the `GIT_CONFIG_*` env protocol -+ * (git ≥2.31) so credentials never appear in argv or the URL. Appends after -+ * any existing `GIT_CONFIG_COUNT` rather than overwriting it. Exported for - * unit tests. - */ - export function buildGitEnv( -@@ -826,21 +619,18 @@ export function buildGitEnv( - GIT_CURL_VERBOSE: undefined, - }; - -- const existing = Number.parseInt(env.GIT_CONFIG_COUNT ?? '', 10); -- let next = Number.isInteger(existing) && existing > 0 ? existing : 0; -- env[`GIT_CONFIG_KEY_${next}`] = 'core.hooksPath'; -- env[`GIT_CONFIG_VALUE_${next}`] = os.devNull; -- next += 1; -- - const credential = resolveGitCredential(options); - const key = options?.url ? buildExtraHeaderKey(options.url) : undefined; - if (credential && key) { -- env[`GIT_CONFIG_KEY_${next}`] = key; -- env[`GIT_CONFIG_VALUE_${next}`] = `Authorization: Basic ${credential}`; -- next += 1; -+ // Append after any GIT_CONFIG_* the operator already set, so we never -+ // clobber their git config (e.g. an enforced http.sslVerify). -+ const existing = Number.parseInt(env.GIT_CONFIG_COUNT ?? '', 10); -+ const base = Number.isInteger(existing) && existing > 0 ? existing : 0; -+ env.GIT_CONFIG_COUNT = String(base + 1); -+ env[`GIT_CONFIG_KEY_${base}`] = key; -+ env[`GIT_CONFIG_VALUE_${base}`] = `Authorization: Basic ${credential}`; - warnIfCleartextCredential(options?.url); - } -- env.GIT_CONFIG_COUNT = String(next); - - return env; - } -@@ -850,65 +640,35 @@ export function buildGitEnv( - // host-scoped Authorization header (GitHub PAT for github.com, else the - // server's AZURE_DEVOPS_PAT for Azure hosts) via the GIT_CONFIG_* protocol — - // never in argv. See resolveGitCredential / buildExtraHeaderKey. --export function runGit(args: string[], cwd?: string, options?: RunGitOptions): Promise { -+function runGit( -+ args: string[], -+ cwd?: string, -+ options?: { token?: string; url?: string }, -+): Promise { - return new Promise((resolve, reject) => { -- const spawnGit = options?.spawnForTest ?? spawn; -- const proc = spawnGit('git', args, { -+ const proc = spawn('git', args, { - cwd, - stdio: ['ignore', 'pipe', 'pipe'], - windowsHide: true, - env: buildGitEnv(process.env, options), - }); - -- let stdout = ''; - let stderr = ''; -- let settled = false; -- let timedOut = false; -- let killTimer: NodeJS.Timeout | undefined; -- const finish = (fn: () => void) => { -- if (settled) return; -- settled = true; -- if (timer) clearTimeout(timer); -- if (killTimer) clearTimeout(killTimer); -- fn(); -- }; -- const timer = -- options?.timeoutMs && options.timeoutMs > 0 -- ? setTimeout(() => { -- timedOut = true; -- proc.kill('SIGTERM'); -- killTimer = setTimeout(() => { -- proc.kill('SIGKILL'); -- finish(() => -- reject(new Error(`git ${args[0]} timed out after ${options.timeoutMs}ms`)), -- ); -- }, options.timeoutKillGraceMs ?? 1_000); -- }, options.timeoutMs) -- : undefined; -- proc.stdout?.on('data', (chunk: Buffer) => { -- stdout += chunk; -- }); - proc.stderr.on('data', (chunk: Buffer) => { - stderr += chunk; - }); - - proc.on('close', (code) => { -- if (timedOut) { -- finish(() => reject(new Error(`git ${args[0]} timed out after ${options?.timeoutMs}ms`))); -- return; -- } -- if (code === 0) finish(() => resolve(stdout)); -+ if (code === 0) resolve(); - else { - // Log full stderr internally but don't expose it to API callers (SSRF mitigation) - if (stderr.trim()) logger.error(`git ${args[0]} stderr: ${stderr.trim()}`); -- finish(() => reject(new Error(`git ${args[0]} failed (exit code ${code})`))); -+ reject(new Error(`git ${args[0]} failed (exit code ${code})`)); - } - }); - - proc.on('error', (err) => { -- finish(() => reject(new Error(`Failed to spawn git: ${err.message}`))); -+ reject(new Error(`Failed to spawn git: ${err.message}`)); - }); - }); - } -- --export const runGitForTest = runGit; -diff --git a/gitnexus/src/storage/file-lock.ts b/gitnexus/src/storage/file-lock.ts -deleted file mode 100644 -index a857d6a47..000000000 ---- a/gitnexus/src/storage/file-lock.ts -+++ /dev/null -@@ -1,189 +0,0 @@ --import crypto from 'node:crypto'; --import fs from 'node:fs/promises'; --import os from 'node:os'; --import path from 'node:path'; --import { setTimeout as sleep } from 'node:timers/promises'; --import { isProcessAlive, readProcessStartTime } from '../utils/process-identity.js'; -- --const HOSTNAME = os.hostname(); -- --export interface FileLockOptions { -- retries?: number; -- retryDelayMs?: number; -- pid?: number; -- processStartTime?: string; -- hostname?: string; -- isProcessAlive?: (pid: number) => boolean; -- readProcessStartTime?: (pid: number) => string | undefined; --} -- --interface FileLockOwner { -- pid: number; -- ownerId: string; -- processStartTime: string; -- hostname: string; --} -- --export class FileLockBusyError extends Error { -- constructor(public readonly lockPath: string) { -- super( -- `Lock is already held: ${lockPath}. Confirm no owner process is active, then remove it manually.`, -- ); -- this.name = 'FileLockBusyError'; -- } --} -- --/** Acquire a recoverable cross-process mutex using an atomically published owner file. */ --export async function acquireFileLock( -- lockPath: string, -- options: FileLockOptions = {}, --): Promise<() => Promise> { -- const resolvedPath = path.resolve(lockPath); -- const retries = options.retries ?? 0; -- const retryDelayMs = options.retryDelayMs ?? 50; -- const pid = options.pid ?? process.pid; -- const owner: FileLockOwner = { -- pid, -- ownerId: crypto.randomUUID(), -- processStartTime: -- options.processStartTime ?? (options.readProcessStartTime ?? readProcessStartTime)(pid) ?? '', -- hostname: options.hostname ?? HOSTNAME, -- }; -- if (!owner.processStartTime) { -- throw new Error(`Unable to determine process start time for file lock owner pid ${owner.pid}.`); -- } -- -- await fs.mkdir(path.dirname(resolvedPath), { recursive: true }); -- const pendingPath = `${resolvedPath}.pending-${owner.ownerId}`; -- await fs.writeFile(pendingPath, `${JSON.stringify(owner)}\n`, { encoding: 'utf-8', flag: 'wx' }); -- -- try { -- for (let attempt = 0; ; attempt += 1) { -- try { -- await fs.link(pendingPath, resolvedPath); -- break; -- } catch (error) { -- if (!(await isLockConflict(error, resolvedPath))) throw error; -- if ( -- await reclaimStaleLock( -- resolvedPath, -- owner, -- options.isProcessAlive ?? isProcessAlive, -- options.readProcessStartTime ?? readProcessStartTime, -- ) -- ) { -- continue; -- } -- if (attempt >= retries) throw new FileLockBusyError(lockPath); -- await sleep(retryDelayMs); -- } -- } -- } finally { -- // The lock is already published by now, but the release closure below is -- // not yet in the caller's hands. Letting a staging-file cleanup error -- // escape would strand a lock nobody can release, so prefer leaking the -- // pending file — its name is per-acquisition, so it can never block anyone. -- await fs.rm(pendingPath, { force: true }).catch(() => {}); -- } -- -- let releasePromise: Promise | undefined; -- return () => (releasePromise ??= releaseOwnedLock(resolvedPath, owner.ownerId)); --} -- --async function reclaimStaleLock( -- lockPath: string, -- guardOwner: FileLockOwner, -- ownerIsAlive: (pid: number) => boolean, -- getProcessStartTime: (pid: number) => string | undefined, --): Promise { -- const reclaimGuardPath = `${lockPath}.reclaim`; -- let releaseReclaimGuard: () => Promise; -- try { -- releaseReclaimGuard = await acquireFileLock(reclaimGuardPath, { -- pid: guardOwner.pid, -- processStartTime: guardOwner.processStartTime, -- hostname: guardOwner.hostname, -- isProcessAlive: ownerIsAlive, -- readProcessStartTime: getProcessStartTime, -- }); -- } catch (error) { -- if (error instanceof FileLockBusyError) return false; -- throw error; -- } -- -- try { -- const owner = await readOwner(lockPath); -- if (!owner) return false; -- // A pid only means something on the machine that issued it. Asking this -- // kernel about a holder on another host answers about an unrelated process -- // — or nothing — and either way the answer is "stale", which would steal a -- // live lock whenever GITNEXUS_HOME is a shared volume. -- if (owner.hostname !== guardOwner.hostname) return false; -- if (ownerIsAlive(owner.pid)) { -- const currentStartTime = getProcessStartTime(owner.pid); -- if (!currentStartTime || currentStartTime === owner.processStartTime) return false; -- } -- -- await fs.rm(lockPath, { force: true }); -- return true; -- } finally { -- await releaseReclaimGuard(); -- } --} -- --async function releaseOwnedLock(lockPath: string, ownerId: string): Promise { -- const releasePath = `${lockPath}.release-${ownerId}-${crypto.randomUUID()}`; -- if (!(await moveOwnedLock(lockPath, releasePath, ownerId))) return; -- await fs.rm(releasePath, { force: true }); --} -- --async function moveOwnedLock( -- lockPath: string, -- destinationPath: string, -- ownerId: string, --): Promise { -- if ((await readOwner(lockPath))?.ownerId !== ownerId) return false; -- try { -- await fs.rename(lockPath, destinationPath); -- } catch (error) { -- if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; -- throw error; -- } -- -- if ((await readOwner(destinationPath))?.ownerId === ownerId) return true; -- await fs.rename(destinationPath, lockPath).catch(() => {}); -- return false; --} -- --async function readOwner(lockPath: string): Promise { -- try { -- const parsed = JSON.parse(await fs.readFile(lockPath, 'utf-8')) as Partial; -- if ( -- Number.isInteger(parsed.pid) && -- Number(parsed.pid) > 0 && -- typeof parsed.ownerId === 'string' && -- parsed.ownerId && -- typeof parsed.processStartTime === 'string' && -- parsed.processStartTime && -- typeof parsed.hostname === 'string' && -- parsed.hostname -- ) { -- return parsed as FileLockOwner; -- } -- } catch { -- // Invalid or legacy locks fail closed; only verified dead owners are reclaimed. -- } -- return undefined; --} -- --async function isLockConflict(error: unknown, lockPath: string): Promise { -- const code = (error as NodeJS.ErrnoException).code; -- if (code === 'EEXIST') return true; -- if (code !== 'EPERM') return false; -- try { -- await fs.access(lockPath); -- return true; -- } catch { -- return false; -- } --} -diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts -index 383f25dcc..3f68df80a 100644 ---- a/gitnexus/src/storage/repo-manager.ts -+++ b/gitnexus/src/storage/repo-manager.ts -@@ -563,9 +563,11 @@ const REGISTRY_LOCK_TIMEOUT_MS = 5_000; - * registry-private lock namespace; the handle is kernel-owned on supported - * platforms and crash-reclaimable by the existing fallback. - * -- * On timeout the transaction fails closed: continuing unlocked would reintroduce -- * the lost-update race this lock exists to prevent and can silently discard a -- * concurrent registration. -+ * On timeout the transaction proceeds UNLOCKED rather than throwing: the lock -+ * closes a lost-update race that existed unguarded before #2716, so degrading -+ * to the old best-effort behaviour is strictly better than failing an -+ * `analyze`/`list`/`augment` outright on a wedged lock (a stale pid-reuse -+ * ghost on platforms without start-time verification can look live forever). - */ - const withRegistryLock = async (operation: () => Promise): Promise => { - let lock: IndexLockHandle | null = null; -@@ -579,13 +581,11 @@ const withRegistryLock = async (operation: () => Promise): Promise => { - logger.info('Waiting for another GitNexus process to finish a registry update…'), - }); - } catch (err) { -- if (err instanceof IndexLockTimeoutError) { -- logger.error( -- { timeoutMs: REGISTRY_LOCK_TIMEOUT_MS }, -- 'Timed out waiting for the global registry lock; refusing an unlocked registry transaction.', -- ); -- } -- throw err; -+ if (!(err instanceof IndexLockTimeoutError)) throw err; -+ logger.warn( -+ { timeoutMs: REGISTRY_LOCK_TIMEOUT_MS }, -+ 'Timed out waiting for the global registry lock; proceeding without it. A concurrent registry write may be lost.', -+ ); - } - try { - return await operation(); -@@ -754,11 +754,14 @@ export const readRegistryStrict = async (): Promise => readRegi - * Atomic tmp+rename: a crash mid-write can never leave a truncated - * registry.json that the next load would treat as empty and silently drop - * every registered repo (#2106 R9). The tmp path must stay per-write — the -- * registry is the one file every gitnexus process on the machine writes (#2888). -+ * registry is the one file every gitnexus process on the machine writes, and -+ * `withRegistryLock` degrades to unlocked on timeout, so the write cannot rely -+ * on the lock to keep two writers off one staging path (#2888). -+ * -+ * `attempts` is forwarded to the rename retry; best-effort callers pass `1`. - */ - const writeRegistry = async (entries: RegistryEntry[], attempts?: number): Promise => { -- const dir = getGlobalDir(); -- await fs.mkdir(dir, { recursive: true }); -+ await fs.mkdir(getGlobalDir(), { recursive: true }); - await writeFileAtomic( - getGlobalRegistryPath(), - JSON.stringify(sanitizeEntries(entries), null, 2), -@@ -1566,6 +1569,8 @@ export const listRegisteredRepos = async (opts?: { - try { - await withRegistryLock(async () => { - const fresh = await readRegistry(); -+ // attempts: 1 — the catch below discards a failure, so the rename -+ // backoff would only make every other process wait out this lock. - await writeRegistry( - fresh.filter((entry) => !pruned.has(entry.path)), - 1, -diff --git a/gitnexus/src/utils/process-identity.ts b/gitnexus/src/utils/process-identity.ts -deleted file mode 100644 -index e7c99439c..000000000 ---- a/gitnexus/src/utils/process-identity.ts -+++ /dev/null -@@ -1,40 +0,0 @@ --import { execFileSync } from 'node:child_process'; -- --export function isProcessAlive(pid: number): boolean { -- try { -- process.kill(pid, 0); -- return true; -- } catch (error) { -- return (error as NodeJS.ErrnoException).code !== 'ESRCH'; -- } --} -- --export function readProcessStartTime(pid: number): string | undefined { -- try { -- const startedAt = -- process.platform === 'win32' -- ? execFileSync( -- 'powershell.exe', -- [ -- '-NoProfile', -- '-NonInteractive', -- '-Command', -- `$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"; if ($p) { $p.CreationDate.ToUniversalTime().ToString("O") }`, -- ], -- { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }, -- ).trim() -- : // `lstart` is rendered through localtime and the active locale, so the -- // same live process yields a different string under a different TZ or -- // LC_TIME. That string is a lock owner's identity, and a mismatch is -- // read as PID reuse — an unpinned render lets one daemon reclaim a -- // mutex another still holds. Pin both so the identity is absolute. -- execFileSync('ps', ['-p', String(pid), '-o', 'lstart='], { -- encoding: 'utf-8', -- stdio: ['ignore', 'pipe', 'ignore'], -- env: { ...process.env, TZ: 'UTC', LC_ALL: 'C' }, -- }).trim(); -- return startedAt || undefined; -- } catch { -- return undefined; -- } --} -diff --git a/gitnexus/test/integration/watch-filesystem.test.ts b/gitnexus/test/integration/watch-filesystem.test.ts -index 51325e1ff..84fa4f8c3 100644 ---- a/gitnexus/test/integration/watch-filesystem.test.ts -+++ b/gitnexus/test/integration/watch-filesystem.test.ts -@@ -3,7 +3,7 @@ import fs from 'node:fs/promises'; - import os from 'node:os'; - import path from 'node:path'; - import { afterEach, describe, expect, it, vi } from 'vitest'; --import { startWatchFileLoop, type WatchFileLoop } from '../../src/cli/analyze-watch.js'; -+import { startWatchFileLoop, type WatchFileLoop } from '../../src/cli/watch.js'; - import { cleanupTempDir } from '../helpers/test-db.js'; - - const tempDirs: string[] = []; -diff --git a/gitnexus/test/unit/auto-sync-analysis-worker.test.ts b/gitnexus/test/unit/auto-sync-analysis-worker.test.ts -deleted file mode 100644 -index b07a30308..000000000 ---- a/gitnexus/test/unit/auto-sync-analysis-worker.test.ts -+++ /dev/null -@@ -1,232 +0,0 @@ --import { EventEmitter } from 'node:events'; --import { describe, expect, it, vi } from 'vitest'; -- --const { autoHeapCapMbMock } = vi.hoisted(() => ({ autoHeapCapMbMock: vi.fn(() => 512) })); --vi.mock('../../src/core/ingestion/utils/effective-ram.js', () => ({ -- autoHeapCapMb: autoHeapCapMbMock, --})); -- --import { createAutoSyncAnalysisRunner } from '../../src/core/auto-sync/analysis-worker-launch.js'; -- --function createChild() { -- return Object.assign(new EventEmitter(), { -- send: vi.fn(), -- stdout: { resume: vi.fn() }, -- stderr: { resume: vi.fn() }, -- }); --} -- --describe('auto-sync analysis worker', () => { -- it('ignores progress and resolves from the terminal complete message', async () => { -- const child = createChild(); -- const forkWorker = vi.fn(() => child as any); -- const run = createAutoSyncAnalysisRunner({ forkWorker }); -- -- const result = run('/tmp/repo', { branch: 'main' }, 50); -- expect(forkWorker).toHaveBeenCalledWith( -- expect.any(String), -- expect.arrayContaining(['--max-old-space-size=512']), -- ); -- child.emit('message', { type: 'progress', phase: 'parsing', percent: 20, message: 'Parsing' }); -- child.emit('message', { type: 'complete', result: { stats: { files: 3 } } }); -- child.emit('exit', 0, null); -- -- await expect(result).resolves.toEqual({ stats: { files: 3 } }); -- expect(child.stdout.resume).toHaveBeenCalled(); -- expect(child.stderr.resume).toHaveBeenCalled(); -- }); -- -- it('rejects on a worker error even when no exit event follows', async () => { -- const child = createChild(); -- const run = createAutoSyncAnalysisRunner({ forkWorker: vi.fn(() => child as any) }); -- const result = run('/tmp/repo', { branch: 'main' }, 50); -- -- child.emit('error', new Error('IPC disconnected')); -- -- expect(child.send).toHaveBeenLastCalledWith({ type: 'cancel' }); -- await expect(result).rejects.toThrow('Auto-sync analyze worker error: IPC disconnected'); -- }); -- -- it('rejects when the initial worker message cannot be sent', async () => { -- const child = createChild(); -- child.send.mockImplementationOnce(() => { -- throw new Error('IPC channel closed'); -- }); -- const run = createAutoSyncAnalysisRunner({ forkWorker: vi.fn(() => child as any) }); -- -- const result = run('/tmp/repo', { branch: 'main' }, 50); -- -- await expect(result).rejects.toThrow( -- 'Failed to start auto-sync analyze worker: IPC channel closed', -- ); -- expect(child.send).toHaveBeenNthCalledWith(2, { type: 'cancel' }); -- }); -- -- it('preserves a worker terminal error', async () => { -- const child = createChild(); -- const run = createAutoSyncAnalysisRunner({ forkWorker: vi.fn(() => child as any) }); -- -- const result = run('/tmp/repo', { branch: 'main' }, 50); -- child.emit('message', { type: 'progress', phase: 'parsing', percent: 20, message: 'Parsing' }); -- child.emit('message', { type: 'error', message: 'parser crashed' }); -- child.emit('exit', 1, null); -- -- await expect(result).rejects.toThrow('parser crashed'); -- }); -- -- it('requests cancellation after timeout, reports it, and waits for exit', async () => { -- const child = createChild(); -- const timers: Array<() => void> = []; -- const onCancellationRequested = vi.fn(); -- const run = createAutoSyncAnalysisRunner({ -- forkWorker: vi.fn(() => child as any), -- setTimeoutFn: vi.fn((callback: () => void) => { -- timers.push(callback); -- return timers.length as any; -- }) as any, -- clearTimeoutFn: vi.fn() as any, -- }); -- -- const result = run('/tmp/repo', { branch: 'main' }, 50, undefined, onCancellationRequested); -- timers[0]!(); -- -- expect(onCancellationRequested).toHaveBeenCalledOnce(); -- expect(child.send).toHaveBeenLastCalledWith({ type: 'cancel' }); -- let settled = false; -- void result.then( -- () => { -- settled = true; -- }, -- () => { -- settled = true; -- }, -- ); -- await Promise.resolve(); -- expect(settled).toBe(false); -- -- child.emit('exit', 0, null); -- await expect(result).rejects.toThrow('Analysis timed out after 50ms'); -- }); -- -- it('keeps the timeout outcome when complete arrives after cancellation begins', async () => { -- const child = createChild(); -- const timers: Array<() => void> = []; -- const run = createAutoSyncAnalysisRunner({ -- forkWorker: vi.fn(() => child as any), -- setTimeoutFn: vi.fn((callback: () => void) => { -- timers.push(callback); -- return timers.length as any; -- }) as any, -- clearTimeoutFn: vi.fn() as any, -- }); -- -- const result = run('/tmp/repo', { branch: 'main' }, 50); -- timers[0]!(); -- child.emit('message', { type: 'complete', result: { stats: { files: 3 } } }); -- child.emit('exit', 0, null); -- -- await expect(result).rejects.toThrow('Analysis timed out after 50ms'); -- }); -- -- it('does not send cancellation after a terminal complete message', async () => { -- const child = createChild(); -- const timers: Array<() => void> = []; -- const run = createAutoSyncAnalysisRunner({ -- forkWorker: vi.fn(() => child as any), -- setTimeoutFn: vi.fn((callback: () => void) => { -- timers.push(callback); -- return timers.length as any; -- }) as any, -- clearTimeoutFn: vi.fn() as any, -- }); -- -- const result = run('/tmp/repo', { branch: 'main' }, 50); -- child.emit('message', { type: 'complete', result: { stats: { files: 3 } } }); -- child.emit('exit', 0, null); -- -- await expect(result).resolves.toEqual({ stats: { files: 3 } }); -- expect(child.send).toHaveBeenCalledTimes(1); -- expect(timers).toHaveLength(1); -- }); -- -- it('divides the worker heap by the number of repos analyzed in parallel', async () => { -- // Stubbed timers so neither run leaves a live timeout behind for the rest -- // of the suite, and both promises are settled before the test returns. -- const timers: Array<() => void> = []; -- const forkChildren: ReturnType[] = []; -- const forkWorker = vi.fn(() => { -- const child = createChild(); -- forkChildren.push(child); -- return child as any; -- }); -- const run = createAutoSyncAnalysisRunner({ -- forkWorker, -- setTimeoutFn: vi.fn((callback: () => void) => { -- timers.push(callback); -- return timers.length as any; -- }) as any, -- clearTimeoutFn: vi.fn() as any, -- }); -- -- const parallel = run('/tmp/repo', { branch: 'main' }, 50, undefined, undefined, 4); -- expect(forkWorker).toHaveBeenLastCalledWith( -- expect.any(String), -- expect.arrayContaining(['--max-old-space-size=128']), -- ); -- -- const solo = run('/tmp/repo', { branch: 'main' }, 50); -- expect(forkWorker).toHaveBeenLastCalledWith( -- expect.any(String), -- expect.arrayContaining(['--max-old-space-size=512']), -- ); -- -- for (const child of forkChildren) { -- child.emit('message', { type: 'complete', result: { stats: { files: 1 } } }); -- child.emit('exit', 0, null); -- } -- await expect(parallel).resolves.toEqual({ stats: { files: 1 } }); -- await expect(solo).resolves.toEqual({ stats: { files: 1 } }); -- }); -- -- it('stops waiting for a worker that never exits after cancellation', async () => { -- const child = Object.assign(createChild(), { -- unref: vi.fn(), -- channel: { unref: vi.fn() }, -- }); -- const timers: Array<() => void> = []; -- const run = createAutoSyncAnalysisRunner({ -- forkWorker: vi.fn(() => child as any), -- setTimeoutFn: vi.fn((callback: () => void) => { -- timers.push(callback); -- return timers.length as any; -- }) as any, -- clearTimeoutFn: vi.fn() as any, -- }); -- -- const result = run('/tmp/repo', { branch: 'main' }, 50); -- timers[0]!(); -- expect(child.send).toHaveBeenLastCalledWith({ type: 'cancel' }); -- -- // No 'exit' ever arrives — the worker is wedged past its safe point. -- timers[1]!(); -- -- await expect(result).rejects.toThrow('did not exit within'); -- // The parent stops waiting; the child is released, never killed. -- expect(child.channel.unref).toHaveBeenCalled(); -- expect(child.unref).toHaveBeenCalled(); -- expect(child.send).toHaveBeenCalledTimes(2); -- }); -- -- it('uses the same cancellation request for an aborted watch run', async () => { -- const child = createChild(); -- const controller = new AbortController(); -- const run = createAutoSyncAnalysisRunner({ forkWorker: vi.fn(() => child as any) }); -- -- const result = run('/tmp/repo', { branch: 'main' }, 50, controller.signal); -- controller.abort(); -- expect(child.send).toHaveBeenLastCalledWith({ type: 'cancel' }); -- -- child.emit('exit', 0, null); -- await expect(result).rejects.toThrow('Analysis cancelled'); -- }); --}); -diff --git a/gitnexus/test/unit/auto-sync-runner.test.ts b/gitnexus/test/unit/auto-sync-runner.test.ts -deleted file mode 100644 -index 2c261f0f0..000000000 ---- a/gitnexus/test/unit/auto-sync-runner.test.ts -+++ /dev/null -@@ -1,1949 +0,0 @@ --import fs from 'node:fs/promises'; --import os from 'node:os'; --import path from 'node:path'; --import { describe, expect, it, vi } from 'vitest'; -- --import { -- addRepoToGroup, -- getAutoSyncRepoIdentity, -- getConfiguredRepoPath, -- getAutoSyncWatchPaths, -- readAutoSyncWatchStatus, -- resolveActualConcurrency, -- runAutoSyncOnce, -- startAutoSyncWatch, -- stopAutoSyncWatch, --} from '../../src/core/auto-sync/index.js'; --import type { -- AutoSyncConfig, -- AutoSyncRunDeps, -- AutoSyncWatchPaths, --} from '../../src/core/auto-sync/index.js'; -- --const config: AutoSyncConfig = { -- configPath: '/tmp/.gitnexus/watch_config.yml', -- syncIntervalMinutes: 10, -- repoGitTimeoutMs: 10_000, -- analyzeTimeoutMs: 1_800_000, -- maxConcurrency: 1, -- analyzeFailureThreshold: 3, -- projects: [ -- { -- localPath: '/tmp/repos', -- groupName: 'back_end', -- overwriteLocalChanges: false, -- branches: ['master'], -- remoteUrls: ['git@gitee.com:qts_server/qts_account.git'], -- }, -- ], --}; -- --const cloneRoot = { -- root: '/tmp/repos', -- quarantineRoot: '/tmp/.gitnexus/watch/quarantine', -- quarantineRetentionDays: 14, --}; --const verifiedWatchCommand = 'node /gitnexus/dist/cli/index.js auto-sync start'; --const verifiedProcessStartTime = 'Tue Aug 4 12:00:00 2026'; -- --function withCloneRoot(deps: Partial): Partial { -- return { -- resolveCloneRoot: vi.fn(async () => cloneRoot), -- ...deps, -- }; --} -- --async function writeWatchOwner( -- paths: AutoSyncWatchPaths, -- pid: number, -- ownerId = `owner-${pid}`, --): Promise { -- await fs.mkdir(path.dirname(paths.pidPath), { recursive: true }); -- await fs.writeFile(paths.pidPath, `${pid}\n`); -- await fs.writeFile( -- paths.mutexPath, -- `${JSON.stringify({ pid, ownerId: `mutex-${ownerId}`, processStartTime: verifiedProcessStartTime, hostname: os.hostname() })}\n`, -- ); -- await fs.writeFile( -- paths.ownerPath, -- `${JSON.stringify({ pid, ownerId, processStartTime: verifiedProcessStartTime, createdAt: '2026-06-30T00:00:00.000Z' })}\n`, -- ); -- await fs.writeFile( -- paths.statusPath, -- `${JSON.stringify({ -- state: 'running', -- pid, -- ownerId, -- updatedAt: '2026-06-30T00:00:00.000Z', -- })}\n`, -- ); -- return ownerId; --} -- --describe('auto-sync runner', () => { -- it('runs clone, analyzes changed commits, registers the repo, and syncs changed groups', async () => { -- const deps: Partial = withCloneRoot({ -- cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), -- getCurrentBranch: vi.fn(() => 'master'), -- getCurrentCommit: vi.fn(() => 'commit-2'), -- runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), -- registerRepo: vi.fn(async () => 'qts_account'), -- loadState: vi.fn(async () => ({ -- '/tmp/repos/gitee.com/qts_server/qts_account|master': { -- codeCommitId: 'commit-1', -- analyzedCommitId: 'commit-1', -- lastAnalyzeStatus: 'success', -- lastSyncTime: '2026-01-01T00:00:00.000Z', -- }, -- })), -- saveState: vi.fn(async () => {}), -- writeCommitInfo: vi.fn(async () => {}), -- addRepoToGroup: vi.fn(async () => true), -- syncGroupByName: vi.fn(async () => {}), -- getAvailableMemoryGB: vi.fn(() => 8), -- }); -- -- const result = await runAutoSyncOnce(config, { -- deps, -- logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, -- now: () => new Date('2026-06-30T00:00:00.000Z'), -- }); -- -- expect(result).toEqual({ synced: 1, analyzed: 1, skippedAnalysis: 0, failed: 0 }); -- expect(deps.cloneOrPull).toHaveBeenCalledWith( -- 'git@gitee.com:qts_server/qts_account.git', -- '/tmp/repos/gitee.com/qts_server/qts_account', -- undefined, -- { -- allowedCloneRoot: '/tmp/repos', -- expectedRepoName: 'qts_account', -- quarantineRoot: '/tmp/.gitnexus/watch/quarantine', -- allowAutoSyncSsh: true, -- timeoutMs: 10_000, -- branch: 'master', -- overwriteLocalChanges: false, -- }, -- ); -- expect(deps.getCurrentBranch).toHaveBeenCalledWith( -- '/tmp/repos/gitee.com/qts_server/qts_account', -- 10_000, -- ); -- expect(deps.runAnalysis).toHaveBeenCalledWith( -- '/tmp/repos/gitee.com/qts_server/qts_account', -- { branch: 'master', skipAgentsMd: true, skipSkills: true }, -- 1_800_000, -- undefined, -- undefined, -- 1, -- ); -- expect(deps.registerRepo).toHaveBeenCalledWith( -- '/tmp/repos/gitee.com/qts_server/qts_account', -- expect.objectContaining({ -- lastCommit: 'commit-2', -- branch: 'master', -- remoteUrl: 'git@gitee.com:qts_server/qts_account.git', -- }), -- { name: 'gitee.com/qts_server/qts_account' }, -- ); -- expect(deps.syncGroupByName).toHaveBeenCalledWith('back_end'); -- expect(deps.writeCommitInfo).toHaveBeenCalledWith([ -- expect.objectContaining({ -- remoteUrl: 'git@gitee.com:qts_server/qts_account.git', -- codeCommitId: 'commit-2', -- analyzedCommitId: 'commit-2', -- status: 'success', -- }), -- ]); -- }); -- -- it('registers into the branch slot the analyze worker placed the index in', async () => { -- // Without this the parent always takes the primary/flat arm and relabels a -- // pinned branch entry with whatever this tick happened to sync. -- const deps: Partial = withCloneRoot({ -- cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), -- getCurrentBranch: vi.fn(async () => 'master'), -- getCurrentCommit: vi.fn(async () => 'commit-2'), -- runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), -- registerRepo: vi.fn(async () => 'qts_account'), -- resolveBranchPlacement: vi.fn(async () => ({ branch: 'master' })), -- loadState: vi.fn(async () => ({})), -- saveState: vi.fn(async () => {}), -- writeCommitInfo: vi.fn(async () => {}), -- addRepoToGroup: vi.fn(async () => true), -- syncGroupByName: vi.fn(async () => {}), -- getAvailableMemoryGB: vi.fn(() => 8), -- }); -- -- await runAutoSyncOnce(config, { -- deps, -- logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, -- now: () => new Date('2026-06-30T00:00:00.000Z'), -- }); -- -- expect(deps.resolveBranchPlacement).toHaveBeenCalledWith( -- '/tmp/repos/gitee.com/qts_server/qts_account', -- 'master', -- ); -- expect(deps.registerRepo).toHaveBeenCalledWith( -- '/tmp/repos/gitee.com/qts_server/qts_account', -- expect.anything(), -- { name: 'gitee.com/qts_server/qts_account', branch: 'master' }, -- ); -- }); -- -- it('syncs a group when a repo is newly added to the group', async () => { -- const deps: Partial = withCloneRoot({ -- cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), -- getCurrentBranch: vi.fn(() => 'master'), -- getCurrentCommit: vi.fn(() => 'commit-2'), -- runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), -- registerRepo: vi.fn(async () => 'qts_account'), -- loadState: vi.fn(async () => ({})), -- saveState: vi.fn(async () => {}), -- writeCommitInfo: vi.fn(async () => {}), -- addRepoToGroup: vi.fn(async () => true), -- syncGroupByName: vi.fn(async () => {}), -- getAvailableMemoryGB: vi.fn(() => 8), -- }); -- -- await runAutoSyncOnce(config, { -- deps, -- logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, -- }); -- -- expect(deps.addRepoToGroup).toHaveBeenCalledWith( -- config.projects[0], -- 'gitee.com/qts_server/qts_account', -- 'gitee.com/qts_server/qts_account', -- ); -- expect(deps.syncGroupByName).toHaveBeenCalledWith('back_end'); -- }); -- -- it('syncs a group after successful re-analysis even when membership already exists', async () => { -- const deps: Partial = withCloneRoot({ -- cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), -- getCurrentBranch: vi.fn(() => 'master'), -- getCurrentCommit: vi.fn(() => 'commit-3'), -- runAnalysis: vi.fn(async () => ({ stats: { files: 2 } }) as any), -- registerRepo: vi.fn(async () => 'qts_account'), -- loadState: vi.fn(async () => ({ -- '/tmp/repos/gitee.com/qts_server/qts_account|master': { -- codeCommitId: 'commit-2', -- analyzedCommitId: 'commit-2', -- lastAnalyzeStatus: 'success', -- lastSyncTime: '2026-01-01T00:00:00.000Z', -- }, -- })), -- saveState: vi.fn(async () => {}), -- writeCommitInfo: vi.fn(async () => {}), -- addRepoToGroup: vi.fn(async () => false), -- syncGroupByName: vi.fn(async () => {}), -- getAvailableMemoryGB: vi.fn(() => 8), -- }); -- -- const result = await runAutoSyncOnce(config, { -- deps, -- logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, -- }); -- -- expect(result.analyzed).toBe(1); -- expect(deps.addRepoToGroup).toHaveBeenCalledWith( -- config.projects[0], -- 'gitee.com/qts_server/qts_account', -- 'gitee.com/qts_server/qts_account', -- ); -- expect(deps.syncGroupByName).toHaveBeenCalledWith('back_end'); -- }); -- -- it('uses distinct registry and group identities for repositories with the same basename', async () => { -- const duplicateConfig: AutoSyncConfig = { -- ...config, -- projects: [ -- { -- localPath: '/tmp/repos-a', -- groupName: 'back_end', -- branches: ['main'], -- remoteUrls: ['git@github.com:team-a/service.git'], -- }, -- { -- localPath: '/tmp/repos-b', -- groupName: 'back_end', -- branches: ['main'], -- remoteUrls: ['git@gitlab.com:team-b/service.git'], -- }, -- ], -- }; -- const deps: Partial = withCloneRoot({ -- resolveCloneRoot: vi.fn(async (localPath: string) => ({ -- ...cloneRoot, -- root: localPath, -- })), -- cloneOrPull: vi.fn(async (_url, targetDir) => targetDir), -- getCurrentBranch: vi.fn(() => 'main'), -- getCurrentCommit: vi.fn(() => 'commit-2'), -- runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), -- registerRepo: vi.fn(async () => 'service'), -- loadState: vi.fn(async () => ({})), -- saveState: vi.fn(async () => {}), -- writeCommitInfo: vi.fn(async () => {}), -- addRepoToGroup: vi.fn(async () => true), -- syncGroupByName: vi.fn(async () => {}), -- getAvailableMemoryGB: vi.fn(() => 8), -- }); -- -- await runAutoSyncOnce(duplicateConfig, { -- deps, -- logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, -- }); -- -- expect(deps.registerRepo).toHaveBeenNthCalledWith( -- 1, -- '/tmp/repos-a/github.com/team-a/service', -- expect.anything(), -- { name: 'github.com/team-a/service' }, -- ); -- expect(deps.registerRepo).toHaveBeenNthCalledWith( -- 2, -- '/tmp/repos-b/gitlab.com/team-b/service', -- expect.anything(), -- { name: 'gitlab.com/team-b/service' }, -- ); -- expect(deps.addRepoToGroup).toHaveBeenCalledWith( -- duplicateConfig.projects[0], -- 'github.com/team-a/service', -- 'github.com/team-a/service', -- ); -- expect(deps.addRepoToGroup).toHaveBeenCalledWith( -- duplicateConfig.projects[1], -- 'gitlab.com/team-b/service', -- 'gitlab.com/team-b/service', -- ); -- }); -- -- it('normalizes the .git suffix case in auto-sync repository identities', () => { -- expect(getAutoSyncRepoIdentity('git@GitHub.com:team/service.GIT')).toBe( -- 'github.com/team/service', -- ); -- }); -- -- it('skips analysis when commit id has not changed', async () => { -- const deps: Partial = withCloneRoot({ -- cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), -- getCurrentBranch: vi.fn(() => 'master'), -- getCurrentCommit: vi.fn(() => 'commit-1'), -- runAnalysis: vi.fn(), -- registerRepo: vi.fn(), -- loadState: vi.fn(async () => ({ -- '/tmp/repos/gitee.com/qts_server/qts_account|master': { -- codeCommitId: 'commit-1', -- analyzedCommitId: 'commit-1', -- lastAnalyzeStatus: 'success', -- lastSyncTime: '2026-01-01T00:00:00.000Z', -- }, -- })), -- saveState: vi.fn(async () => {}), -- writeCommitInfo: vi.fn(async () => {}), -- addRepoToGroup: vi.fn(async () => true), -- syncGroupByName: vi.fn(async () => {}), -- getAvailableMemoryGB: vi.fn(() => 8), -- }); -- -- const result = await runAutoSyncOnce(config, { -- deps, -- logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, -- }); -- -- expect(result.analyzed).toBe(0); -- expect(result.skippedAnalysis).toBe(1); -- expect(deps.runAnalysis).not.toHaveBeenCalled(); -- expect(deps.syncGroupByName).toHaveBeenCalledWith('back_end'); -- }); -- -- it('retries a failed group sync on the next unchanged commit without re-analysis', async () => { -- let persistedState: any = { -- '/tmp/repos/gitee.com/qts_server/qts_account|master': { -- codeCommitId: 'commit-1', -- analyzedCommitId: 'commit-1', -- lastAnalyzeStatus: 'success', -- groupSyncPending: true, -- lastSyncTime: '2026-01-01T00:00:00.000Z', -- }, -- }; -- const syncGroupByName = vi -- .fn() -- .mockRejectedValueOnce(new Error('group temporarily unavailable')) -- .mockResolvedValueOnce(undefined); -- const deps: Partial = withCloneRoot({ -- cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), -- getCurrentBranch: vi.fn(() => 'master'), -- getCurrentCommit: vi.fn(() => 'commit-1'), -- runAnalysis: vi.fn(), -- registerRepo: vi.fn(), -- loadState: vi.fn(async () => structuredClone(persistedState)), -- saveState: vi.fn(async (state) => { -- persistedState = structuredClone(state); -- }), -- writeCommitInfo: vi.fn(async () => {}), -- addRepoToGroup: vi.fn(async () => false), -- syncGroupByName, -- getAvailableMemoryGB: vi.fn(() => 8), -- }); -- const runOptions = { -- deps, -- logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, -- }; -- -- const first = await runAutoSyncOnce(config, runOptions); -- const second = await runAutoSyncOnce(config, runOptions); -- -- expect(first.failed).toBe(1); -- expect(second.failed).toBe(0); -- expect(deps.runAnalysis).not.toHaveBeenCalled(); -- expect(syncGroupByName).toHaveBeenCalledTimes(2); -- expect( -- persistedState['/tmp/repos/gitee.com/qts_server/qts_account|master'].groupSyncPending, -- ).toBe(false); -- }); -- -- it('uses remote identity under local_path as the clone target', async () => { -- expect( -- getConfiguredRepoPath( -- config.projects[0], -- 'qts_account', -- 'git@gitee.com:qts_server/qts_account.git', -- ), -- ).toBe('/tmp/repos/gitee.com/qts_server/qts_account'); -- -- const deps: Partial = withCloneRoot({ -- cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), -- getCurrentBranch: vi.fn(() => 'master'), -- getCurrentCommit: vi.fn(() => 'commit-2'), -- runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), -- registerRepo: vi.fn(async () => 'qts_account'), -- loadState: vi.fn(async () => ({})), -- saveState: vi.fn(async () => {}), -- writeCommitInfo: vi.fn(async () => {}), -- addRepoToGroup: vi.fn(async () => false), -- syncGroupByName: vi.fn(async () => {}), -- getAvailableMemoryGB: vi.fn(() => 8), -- }); -- -- await runAutoSyncOnce(config, { -- deps, -- logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, -- }); -- -- expect(deps.cloneOrPull).toHaveBeenCalledWith( -- 'git@gitee.com:qts_server/qts_account.git', -- '/tmp/repos/gitee.com/qts_server/qts_account', -- undefined, -- { -- allowedCloneRoot: '/tmp/repos', -- expectedRepoName: 'qts_account', -- quarantineRoot: '/tmp/.gitnexus/watch/quarantine', -- allowAutoSyncSsh: true, -- timeoutMs: 10_000, -- branch: 'master', -- overwriteLocalChanges: false, -- }, -- ); -- }); -- -- it('passes watch cancellation controls to the isolated analysis runner', async () => { -- const controller = new AbortController(); -- const onAnalysisCancellationRequested = vi.fn(); -- const runAnalysis = vi.fn(async () => ({ stats: { files: 1 } }) as any); -- const deps: Partial = withCloneRoot({ -- cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), -- getCurrentBranch: vi.fn(() => 'master'), -- getCurrentCommit: vi.fn(() => 'commit-2'), -- runAnalysis, -- registerRepo: vi.fn(async () => 'qts_account'), -- loadState: vi.fn(async () => ({})), -- saveState: vi.fn(async () => {}), -- writeCommitInfo: vi.fn(async () => {}), -- addRepoToGroup: vi.fn(async () => false), -- syncGroupByName: vi.fn(async () => {}), -- getAvailableMemoryGB: vi.fn(() => 8), -- }); -- -- await runAutoSyncOnce(config, { -- deps, -- logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, -- signal: controller.signal, -- onAnalysisCancellationRequested, -- }); -- -- expect(runAnalysis).toHaveBeenCalledWith( -- '/tmp/repos/gitee.com/qts_server/qts_account', -- { branch: 'master', skipAgentsMd: true, skipSkills: true }, -- 1_800_000, -- controller.signal, -- onAnalysisCancellationRequested, -- 1, -- ); -- }); -- -- it('falls back through configured branches and analyzes the first pullable branch', async () => { -- const warnLogger = vi.fn(); -- const errorLogger = vi.fn(); -- const branchConfig: AutoSyncConfig = { -- ...config, -- projects: [{ ...config.projects[0], branches: ['missing', 'develop'] }], -- }; -- const deps: Partial = withCloneRoot({ -- cloneOrPull: vi.fn(async (_remoteUrl, _targetDir, _progress, options) => { -- if (options?.branch === 'missing') throw new Error('remote branch not found'); -- return '/tmp/repos/gitee.com/qts_server/qts_account'; -- }), -- getCurrentBranch: vi.fn(() => 'develop'), -- getCurrentCommit: vi.fn(() => 'commit-2'), -- runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), -- registerRepo: vi.fn(async () => 'qts_account'), -- loadState: vi.fn(async () => ({})), -- saveState: vi.fn(async () => {}), -- writeCommitInfo: vi.fn(async () => {}), -- addRepoToGroup: vi.fn(async () => false), -- syncGroupByName: vi.fn(async () => {}), -- getAvailableMemoryGB: vi.fn(() => 8), -- }); -- -- const result = await runAutoSyncOnce(branchConfig, { -- deps, -- logger: { info: vi.fn(), warn: warnLogger, error: errorLogger }, -- }); -- -- expect(result).toEqual({ synced: 1, analyzed: 1, skippedAnalysis: 0, failed: 0 }); -- expect(deps.cloneOrPull).toHaveBeenNthCalledWith( -- 1, -- 'git@gitee.com:qts_server/qts_account.git', -- '/tmp/repos/gitee.com/qts_server/qts_account', -- undefined, -- expect.objectContaining({ branch: 'missing' }), -- ); -- expect(deps.cloneOrPull).toHaveBeenNthCalledWith( -- 2, -- 'git@gitee.com:qts_server/qts_account.git', -- '/tmp/repos/gitee.com/qts_server/qts_account', -- undefined, -- expect.objectContaining({ branch: 'develop' }), -- ); -- expect(deps.runAnalysis).toHaveBeenCalledWith( -- '/tmp/repos/gitee.com/qts_server/qts_account', -- { branch: 'develop', skipAgentsMd: true, skipSkills: true }, -- 1_800_000, -- undefined, -- undefined, -- 1, -- ); -- expect(warnLogger).toHaveBeenCalledWith( -- '[auto-sync] Branch missing unavailable for git@gitee.com:qts_server/qts_account.git: remote branch not found', -- ); -- expect(errorLogger).not.toHaveBeenCalled(); -- }); -- -- it('records branch_unavailable when all configured branches fail', async () => { -- const warnLogger = vi.fn(); -- const errorLogger = vi.fn(); -- const branchConfig: AutoSyncConfig = { -- ...config, -- projects: [{ ...config.projects[0], branches: ['missing', 'develop'] }], -- }; -- const deps: Partial = withCloneRoot({ -- cloneOrPull: vi.fn(async () => { -- throw new Error('remote branch not found'); -- }), -- getCurrentBranch: vi.fn(), -- getCurrentCommit: vi.fn(), -- runAnalysis: vi.fn(), -- registerRepo: vi.fn(), -- loadState: vi.fn(async () => ({})), -- saveState: vi.fn(async () => {}), -- writeCommitInfo: vi.fn(async () => {}), -- addRepoToGroup: vi.fn(async () => false), -- syncGroupByName: vi.fn(async () => {}), -- getAvailableMemoryGB: vi.fn(() => 8), -- }); -- -- const result = await runAutoSyncOnce(branchConfig, { -- deps, -- logger: { info: vi.fn(), warn: warnLogger, error: errorLogger }, -- now: () => new Date('2026-06-30T00:00:00.000Z'), -- }); -- -- expect(result).toEqual({ synced: 0, analyzed: 0, skippedAnalysis: 0, failed: 1 }); -- expect(deps.cloneOrPull).toHaveBeenCalledTimes(2); -- expect(deps.writeCommitInfo).toHaveBeenCalledWith([ -- expect.objectContaining({ -- branch: 'missing', -- status: 'branch_unavailable', -- }), -- ]); -- expect(deps.getCurrentCommit).not.toHaveBeenCalled(); -- expect(warnLogger).toHaveBeenCalledTimes(2); -- expect(warnLogger).toHaveBeenCalledWith( -- '[auto-sync] Branch missing unavailable for git@gitee.com:qts_server/qts_account.git: remote branch not found', -- ); -- expect(warnLogger).toHaveBeenCalledWith( -- '[auto-sync] Branch develop unavailable for git@gitee.com:qts_server/qts_account.git: remote branch not found', -- ); -- expect(errorLogger).toHaveBeenCalledTimes(1); -- expect(errorLogger).toHaveBeenCalledWith( -- '[auto-sync] Repository sync failed for git@gitee.com:qts_server/qts_account.git; no configured branch could be pulled: missing: remote branch not found; develop: remote branch not found', -- ); -- }); -- -- it('records branch_unavailable when checkout ends on an unexpected branch', async () => { -- const warnLogger = vi.fn(); -- const deps: Partial = withCloneRoot({ -- cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), -- getCurrentBranch: vi.fn(() => 'develop'), -- getCurrentCommit: vi.fn(), -- runAnalysis: vi.fn(), -- registerRepo: vi.fn(), -- loadState: vi.fn(async () => ({})), -- saveState: vi.fn(async () => {}), -- writeCommitInfo: vi.fn(async () => {}), -- addRepoToGroup: vi.fn(async () => true), -- syncGroupByName: vi.fn(async () => {}), -- getAvailableMemoryGB: vi.fn(() => 8), -- }); -- -- const result = await runAutoSyncOnce(config, { -- deps, -- logger: { info: vi.fn(), warn: warnLogger, error: vi.fn() }, -- }); -- -- expect(result).toEqual({ synced: 0, analyzed: 0, skippedAnalysis: 0, failed: 1 }); -- expect(deps.getCurrentCommit).not.toHaveBeenCalled(); -- expect(deps.runAnalysis).not.toHaveBeenCalled(); -- expect(deps.addRepoToGroup).not.toHaveBeenCalled(); -- expect(warnLogger).toHaveBeenCalledWith( -- '[auto-sync] Branch master for git@gitee.com:qts_server/qts_account.git synced but current branch is develop; trying next branch.', -- ); -- }); -- -- it('records branch_unavailable when the checked out repository is detached', async () => { -- const warnLogger = vi.fn(); -- const deps: Partial = withCloneRoot({ -- cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), -- getCurrentBranch: vi.fn(() => undefined), -- getCurrentCommit: vi.fn(), -- runAnalysis: vi.fn(), -- registerRepo: vi.fn(), -- loadState: vi.fn(async () => ({})), -- saveState: vi.fn(async () => {}), -- writeCommitInfo: vi.fn(async () => {}), -- addRepoToGroup: vi.fn(async () => true), -- syncGroupByName: vi.fn(async () => {}), -- getAvailableMemoryGB: vi.fn(() => 8), -- }); -- -- const result = await runAutoSyncOnce(config, { -- deps, -- logger: { info: vi.fn(), warn: warnLogger, error: vi.fn() }, -- }); -- -- expect(result).toEqual({ synced: 0, analyzed: 0, skippedAnalysis: 0, failed: 1 }); -- expect(deps.getCurrentCommit).not.toHaveBeenCalled(); -- expect(deps.runAnalysis).not.toHaveBeenCalled(); -- expect(deps.addRepoToGroup).not.toHaveBeenCalled(); -- expect(warnLogger).toHaveBeenCalledWith( -- '[auto-sync] Branch master for git@gitee.com:qts_server/qts_account.git synced but current branch is ; trying next branch.', -- ); -- }); -- -- it('isolates repository and analysis failures without syncing groups for failed analysis', async () => { -- const errorLogger = vi.fn(); -- const failingConfig: AutoSyncConfig = { -- ...config, -- projects: [ -- { -- ...config.projects[0], -- remoteUrls: [ -- 'git@gitee.com:qts_server/failing_sync.git', -- 'git@gitee.com:qts_server/qts_account.git', -- ], -- }, -- ], -- }; -- const deps: Partial = withCloneRoot({ -- cloneOrPull: vi.fn(async (remoteUrl) => { -- if (remoteUrl.includes('failing_sync')) throw new Error('sync failed'); -- return '/tmp/repos/gitee.com/qts_server/qts_account'; -- }), -- getCurrentBranch: vi.fn(() => 'master'), -- getCurrentCommit: vi.fn(() => 'commit-2'), -- runAnalysis: vi.fn(async () => { -- throw new Error('analysis failed'); -- }), -- registerRepo: vi.fn(), -- loadState: vi.fn(async () => ({ -- '/tmp/repos/gitee.com/qts_server/qts_account|master': { -- codeCommitId: 'commit-1', -- analyzedCommitId: 'commit-1', -- lastAnalyzeStatus: 'success', -- groupSyncPending: true, -- lastSyncTime: '2026-06-29T00:00:00.000Z', -- }, -- })), -- saveState: vi.fn(async () => {}), -- writeCommitInfo: vi.fn(async () => {}), -- addRepoToGroup: vi.fn(async () => true), -- syncGroupByName: vi.fn(async () => {}), -- getAvailableMemoryGB: vi.fn(() => 8), -- }); -- -- const result = await runAutoSyncOnce(failingConfig, { -- deps, -- logger: { info: vi.fn(), warn: vi.fn(), error: errorLogger }, -- now: () => new Date('2026-06-30T00:00:00.000Z'), -- }); -- -- expect(result).toEqual({ synced: 1, analyzed: 0, skippedAnalysis: 0, failed: 2 }); -- expect(deps.cloneOrPull).toHaveBeenCalledTimes(2); -- expect(deps.registerRepo).not.toHaveBeenCalled(); -- expect(deps.addRepoToGroup).toHaveBeenCalledWith( -- failingConfig.projects[0], -- 'gitee.com/qts_server/qts_account', -- 'gitee.com/qts_server/qts_account', -- ); -- expect(deps.syncGroupByName).not.toHaveBeenCalled(); -- expect(deps.saveState).toHaveBeenCalledWith( -- expect.objectContaining({ -- '/tmp/repos/gitee.com/qts_server/qts_account|master': expect.objectContaining({ -- codeCommitId: 'commit-2', -- lastAnalyzeStatus: 'failed', -- }), -- }), -- ); -- expect(errorLogger).toHaveBeenCalledWith( -- expect.stringContaining( -- 'Repository sync failed for git@gitee.com:qts_server/failing_sync.git', -- ), -- ); -- expect(errorLogger).toHaveBeenCalledWith( -- expect.stringContaining('Analysis failed for /tmp/repos/gitee.com/qts_server/qts_account'), -- ); -- }); -- -- it('records the resolved target directory when a post-sync operation fails', async () => { -- const deps: Partial = withCloneRoot({ -- cloneOrPull: vi.fn(async (_url, targetDir) => targetDir), -- getCurrentBranch: vi.fn(() => 'master'), -- getCurrentCommit: vi.fn(() => { -- throw new Error('git log failed'); -- }), -- loadState: vi.fn(async () => ({})), -- saveState: vi.fn(async () => {}), -- writeCommitInfo: vi.fn(async () => {}), -- getAvailableMemoryGB: vi.fn(() => 8), -- }); -- -- await expect( -- runAutoSyncOnce(config, { -- deps, -- logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, -- }), -- ).resolves.toEqual({ synced: 0, analyzed: 0, skippedAnalysis: 0, failed: 1 }); -- -- expect(deps.writeCommitInfo).toHaveBeenCalledWith([ -- expect.objectContaining({ -- remoteUrl: 'git@gitee.com:qts_server/qts_account.git', -- localPath: '/tmp/repos/gitee.com/qts_server/qts_account', -- status: 'sync_failed', -- }), -- ]); -- }); -- -- it('isolates clone-root resolution failures to the affected project', async () => { -- const isolatedConfig: AutoSyncConfig = { -- ...config, -- projects: [ -- { ...config.projects[0], localPath: '/bad/repos' }, -- { ...config.projects[0], localPath: '/tmp/repos' }, -- ], -- }; -- const deps: Partial = withCloneRoot({ -- resolveCloneRoot: vi.fn(async (localPath: string) => { -- if (localPath === '/bad/repos') throw new Error('unsafe clone root'); -- return cloneRoot; -- }), -- cloneOrPull: vi.fn(async (_url, targetDir) => targetDir), -- getCurrentBranch: vi.fn(() => 'master'), -- getCurrentCommit: vi.fn(() => 'commit-2'), -- runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), -- registerRepo: vi.fn(async () => 'repo'), -- loadState: vi.fn(async () => ({})), -- saveState: vi.fn(async () => {}), -- writeCommitInfo: vi.fn(async () => {}), -- addRepoToGroup: vi.fn(async () => false), -- syncGroupByName: vi.fn(async () => {}), -- getAvailableMemoryGB: vi.fn(() => 8), -- }); -- -- await expect( -- runAutoSyncOnce(isolatedConfig, { -- deps, -- logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, -- }), -- ).resolves.toEqual({ synced: 1, analyzed: 1, skippedAnalysis: 0, failed: 1 }); -- expect(deps.cloneOrPull).toHaveBeenCalledTimes(1); -- expect(deps.saveState).toHaveBeenCalledTimes(1); -- expect(deps.writeCommitInfo).toHaveBeenCalledTimes(1); -- }); -- -- it('persists state and commit info when repository registration fails', async () => { -- const errorLogger = vi.fn(); -- const deps: Partial = withCloneRoot({ -- cloneOrPull: vi.fn(async (_url, targetDir) => targetDir), -- getCurrentBranch: vi.fn(() => 'master'), -- getCurrentCommit: vi.fn(() => 'commit-2'), -- runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), -- registerRepo: vi.fn(async () => { -- throw new Error('registry busy'); -- }), -- loadState: vi.fn(async () => ({})), -- saveState: vi.fn(async () => {}), -- writeCommitInfo: vi.fn(async () => {}), -- addRepoToGroup: vi.fn(async () => false), -- syncGroupByName: vi.fn(async () => {}), -- getAvailableMemoryGB: vi.fn(() => 8), -- }); -- -- const result = await runAutoSyncOnce(config, { -- deps, -- logger: { info: vi.fn(), warn: vi.fn(), error: errorLogger }, -- now: () => new Date('2026-06-30T00:00:00.000Z'), -- }); -- -- expect(result).toEqual({ synced: 1, analyzed: 0, skippedAnalysis: 0, failed: 1 }); -- expect(deps.saveState).toHaveBeenCalledWith( -- expect.objectContaining({ -- '/tmp/repos/gitee.com/qts_server/qts_account|master': expect.objectContaining({ -- lastAnalyzeStatus: 'failed', -- analyzedCommitId: undefined, -- lastAnalyzeError: 'Repository registration failed: registry busy', -- }), -- }), -- ); -- expect(deps.writeCommitInfo).toHaveBeenCalledTimes(1); -- expect(errorLogger).toHaveBeenCalledWith( -- '[auto-sync] Repository registration failed: registry busy', -- ); -- }); -- -- it('reports group sync failures after successful analysis', async () => { -- const errorLogger = vi.fn(); -- const deps: Partial = withCloneRoot({ -- cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), -- getCurrentBranch: vi.fn(() => 'master'), -- getCurrentCommit: vi.fn(() => 'commit-2'), -- runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), -- registerRepo: vi.fn(async () => 'qts_account'), -- loadState: vi.fn(async () => ({})), -- saveState: vi.fn(async () => {}), -- writeCommitInfo: vi.fn(async () => {}), -- addRepoToGroup: vi.fn(async () => false), -- syncGroupByName: vi.fn(async () => { -- throw new Error('group sync failed'); -- }), -- getAvailableMemoryGB: vi.fn(() => 8), -- }); -- -- const result = await runAutoSyncOnce(config, { -- deps, -- logger: { info: vi.fn(), warn: vi.fn(), error: errorLogger }, -- }); -- -- expect(result).toEqual({ synced: 1, analyzed: 1, skippedAnalysis: 0, failed: 1 }); -- expect(deps.addRepoToGroup).toHaveBeenCalledWith( -- config.projects[0], -- 'gitee.com/qts_server/qts_account', -- 'gitee.com/qts_server/qts_account', -- ); -- expect(deps.syncGroupByName).toHaveBeenCalledWith('back_end'); -- expect(errorLogger).toHaveBeenCalledWith( -- expect.stringContaining('Group sync failed for back_end'), -- ); -- }); -- -- it('caps actual concurrency by available memory and runs clone/analyze work concurrently', async () => { -- const events: string[] = []; -- let releaseFirstClone: (() => void) | undefined; -- const concurrentConfig: AutoSyncConfig = { -- ...config, -- maxConcurrency: 4, -- projects: [ -- { -- ...config.projects[0], -- groupName: undefined, -- remoteUrls: ['git@github.com:owner/one.git', 'git@gitlab.com:owner/two.git'], -- }, -- ], -- }; -- const deps: Partial = withCloneRoot({ -- cloneOrPull: vi.fn(async (remoteUrl) => { -- events.push(`clone-start:${remoteUrl}`); -- if (remoteUrl.includes('/one.git')) { -- await new Promise((resolve) => { -- releaseFirstClone = resolve; -- setTimeout(resolve, 0); -- }); -- } else { -- releaseFirstClone?.(); -- } -- events.push(`clone-end:${remoteUrl}`); -- return remoteUrl.includes('/one.git') ? '/tmp/repos/one' : '/tmp/repos/two'; -- }), -- getCurrentBranch: vi.fn(() => 'master'), -- getCurrentCommit: vi.fn((repoPath) => -- repoPath.endsWith('/one') ? 'one-commit' : 'two-commit', -- ), -- runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), -- registerRepo: vi.fn(async () => 'repo'), -- loadState: vi.fn(async () => ({})), -- saveState: vi.fn(async () => {}), -- writeCommitInfo: vi.fn(async () => {}), -- addRepoToGroup: vi.fn(async () => false), -- syncGroupByName: vi.fn(async () => {}), -- getAvailableMemoryGB: vi.fn(() => 4), -- }); -- const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; -- -- const result = await runAutoSyncOnce(concurrentConfig, { deps, logger }); -- -- expect(result.synced).toBe(2); -- expect(logger.info).toHaveBeenCalledWith( -- '[auto-sync] Starting sync loop with max_concurrency=2 analyze_failure_threshold=3.', -- ); -- expect(events.slice(0, 2)).toEqual([ -- 'clone-start:git@github.com:owner/one.git', -- 'clone-start:git@gitlab.com:owner/two.git', -- ]); -- expect(deps.registerRepo).toHaveBeenCalledTimes(2); -- expect(deps.saveState).toHaveBeenCalledTimes(1); -- expect(deps.writeCommitInfo).toHaveBeenCalledTimes(1); -- }); -- -- it('keeps same-basename remotes in distinct clone directories', async () => { -- const duplicateConfig: AutoSyncConfig = { -- ...config, -- maxConcurrency: 2, -- projects: [ -- { -- ...config.projects[0], -- branches: ['main'], -- remoteUrls: ['git@github.com:owner/repo.git', 'git@gitlab.com:group/repo.git'], -- }, -- ], -- }; -- const deps: Partial = withCloneRoot({ -- cloneOrPull: vi.fn(async (_url, targetDir) => targetDir), -- getCurrentBranch: vi.fn(() => 'main'), -- getCurrentCommit: vi.fn(() => 'commit-2'), -- runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), -- registerRepo: vi.fn(async (_path, _meta, options) => options?.name ?? 'repo'), -- loadState: vi.fn(async () => ({})), -- saveState: vi.fn(async () => {}), -- writeCommitInfo: vi.fn(async () => {}), -- addRepoToGroup: vi.fn(async () => false), -- syncGroupByName: vi.fn(async () => {}), -- getAvailableMemoryGB: vi.fn(() => 8), -- }); -- -- await expect( -- runAutoSyncOnce(duplicateConfig, { -- deps, -- logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, -- }), -- ).resolves.toEqual({ synced: 2, analyzed: 2, skippedAnalysis: 0, failed: 0 }); -- -- expect(deps.cloneOrPull).toHaveBeenNthCalledWith( -- 1, -- 'git@github.com:owner/repo.git', -- '/tmp/repos/github.com/owner/repo', -- undefined, -- expect.any(Object), -- ); -- expect(deps.cloneOrPull).toHaveBeenNthCalledWith( -- 2, -- 'git@gitlab.com:group/repo.git', -- '/tmp/repos/gitlab.com/group/repo', -- undefined, -- expect.any(Object), -- ); -- expect(deps.saveState).toHaveBeenCalledTimes(1); -- expect(deps.writeCommitInfo).toHaveBeenCalledTimes(1); -- }); -- -- it('rejects non auto-sync SSH URLs at runner boundary', async () => { -- const invalidConfig: AutoSyncConfig = { -- ...config, -- projects: [{ ...config.projects[0], remoteUrls: ['https://github.com/owner/repo.git'] }], -- }; -- const deps: Partial = withCloneRoot({ -- cloneOrPull: vi.fn(), -- loadState: vi.fn(async () => ({})), -- saveState: vi.fn(async () => {}), -- writeCommitInfo: vi.fn(async () => {}), -- addRepoToGroup: vi.fn(async () => false), -- syncGroupByName: vi.fn(async () => {}), -- getAvailableMemoryGB: vi.fn(() => 8), -- }); -- -- const result = await runAutoSyncOnce(invalidConfig, { -- deps, -- logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, -- }); -- -- expect(result.failed).toBe(1); -- expect(deps.cloneOrPull).not.toHaveBeenCalled(); -- }); -- -- it('resets consecutive analyze failures when the code commit changes, then records this failure', async () => { -- const errorLogger = vi.fn(); -- const deps: Partial = withCloneRoot({ -- cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), -- getCurrentBranch: vi.fn(() => 'master'), -- getCurrentCommit: vi.fn(() => 'commit-2'), -- runAnalysis: vi.fn(async () => { -- throw new Error('parser crashed\nwith stack'); -- }), -- registerRepo: vi.fn(), -- loadState: vi.fn(async () => ({ -- '/tmp/repos/gitee.com/qts_server/qts_account|master': { -- codeCommitId: 'commit-1', -- analyzedCommitId: 'commit-1', -- lastAnalyzeStatus: 'failed', -- // New code commit (commit-1 → commit-2) zeros this before the failed analysis increments to 1. -- analyzeConsecutiveFailures: 1, -- lastAnalyzeError: 'old error', -- lastSyncTime: '2026-01-01T00:00:00.000Z', -- }, -- })), -- saveState: vi.fn(async () => {}), -- writeCommitInfo: vi.fn(async () => {}), -- addRepoToGroup: vi.fn(async () => false), -- syncGroupByName: vi.fn(async () => {}), -- getAvailableMemoryGB: vi.fn(() => 8), -- }); -- -- const result = await runAutoSyncOnce(config, { -- deps, -- logger: { info: vi.fn(), warn: vi.fn(), error: errorLogger }, -- now: () => new Date('2026-06-30T00:00:00.000Z'), -- }); -- -- expect(result).toEqual({ synced: 1, analyzed: 0, skippedAnalysis: 0, failed: 1 }); -- expect(deps.saveState).toHaveBeenCalledWith( -- expect.objectContaining({ -- '/tmp/repos/gitee.com/qts_server/qts_account|master': expect.objectContaining({ -- analyzeConsecutiveFailures: 1, -- lastAnalyzeError: 'parser crashed with stack', -- lastAnalyzeStatus: 'failed', -- }), -- }), -- ); -- expect(deps.writeCommitInfo).toHaveBeenCalledWith([ -- expect.objectContaining({ -- status: 'failed', -- analyzeConsecutiveFailures: 1, -- analyzeFailureThreshold: 3, -- lastAnalyzeError: 'parser crashed with stack', -- }), -- ]); -- expect(errorLogger).toHaveBeenCalledWith( -- '[auto-sync] Analysis failed for /tmp/repos/gitee.com/qts_server/qts_account; consecutive failures 1/3: parser crashed with stack', -- ); -- }); -- -- it('records a null analysis failure without masking it with a TypeError', async () => { -- const deps: Partial = withCloneRoot({ -- cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), -- getCurrentBranch: vi.fn(() => 'master'), -- getCurrentCommit: vi.fn(() => 'commit-2'), -- runAnalysis: vi.fn(async () => { -- throw null; -- }), -- registerRepo: vi.fn(), -- loadState: vi.fn(async () => ({})), -- saveState: vi.fn(async () => {}), -- writeCommitInfo: vi.fn(async () => {}), -- addRepoToGroup: vi.fn(async () => false), -- syncGroupByName: vi.fn(async () => {}), -- getAvailableMemoryGB: vi.fn(() => 8), -- }); -- -- await expect( -- runAutoSyncOnce(config, { -- deps, -- logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, -- now: () => new Date('2026-06-30T00:00:00.000Z'), -- }), -- ).resolves.toEqual({ synced: 1, analyzed: 0, skippedAnalysis: 0, failed: 1 }); -- -- expect(deps.saveState).toHaveBeenCalledWith( -- expect.objectContaining({ -- '/tmp/repos/gitee.com/qts_server/qts_account|master': expect.objectContaining({ -- lastAnalyzeError: 'null', -- }), -- }), -- ); -- }); -- -- it('retries analysis on a new commit after consecutive failures reached the threshold', async () => { -- const errorLogger = vi.fn(); -- const deps: Partial = withCloneRoot({ -- cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), -- getCurrentBranch: vi.fn(() => 'master'), -- getCurrentCommit: vi.fn(() => 'commit-2'), -- runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), -- registerRepo: vi.fn(async () => 'qts_account'), -- loadState: vi.fn(async () => ({ -- '/tmp/repos/gitee.com/qts_server/qts_account|master': { -- codeCommitId: 'commit-1', -- analyzedCommitId: 'commit-1', -- lastAnalyzeStatus: 'failed', -- analyzeConsecutiveFailures: 3, -- lastAnalyzeError: 'parser crashed', -- lastSyncTime: '2026-01-01T00:00:00.000Z', -- }, -- })), -- saveState: vi.fn(async () => {}), -- writeCommitInfo: vi.fn(async () => {}), -- addRepoToGroup: vi.fn(async () => false), -- syncGroupByName: vi.fn(async () => {}), -- getAvailableMemoryGB: vi.fn(() => 8), -- }); -- -- const result = await runAutoSyncOnce(config, { -- deps, -- logger: { info: vi.fn(), warn: vi.fn(), error: errorLogger }, -- now: () => new Date('2026-06-30T00:00:00.000Z'), -- }); -- -- expect(result).toEqual({ synced: 1, analyzed: 1, skippedAnalysis: 0, failed: 0 }); -- expect(deps.runAnalysis).toHaveBeenCalledTimes(1); -- expect(deps.saveState).toHaveBeenCalledWith( -- expect.objectContaining({ -- '/tmp/repos/gitee.com/qts_server/qts_account|master': expect.objectContaining({ -- analyzeConsecutiveFailures: 0, -- lastAnalyzeError: undefined, -- lastAnalyzeStatus: 'success', -- }), -- }), -- ); -- expect(deps.writeCommitInfo).toHaveBeenCalledWith([ -- expect.objectContaining({ -- status: 'success', -- analyzeConsecutiveFailures: 0, -- analyzeFailureThreshold: 3, -- lastAnalyzeError: undefined, -- }), -- ]); -- expect(errorLogger).not.toHaveBeenCalled(); -- }); -- -- it('clears prior analyze failure count after a successful analyze', async () => { -- const deps: Partial = withCloneRoot({ -- cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), -- getCurrentBranch: vi.fn(() => 'master'), -- getCurrentCommit: vi.fn(() => 'commit-2'), -- runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), -- registerRepo: vi.fn(async () => 'qts_account'), -- loadState: vi.fn(async () => ({ -- '/tmp/repos/gitee.com/qts_server/qts_account|master': { -- codeCommitId: 'commit-1', -- analyzedCommitId: 'commit-1', -- lastAnalyzeStatus: 'failed', -- analyzeConsecutiveFailures: 2, -- lastAnalyzeError: 'old error', -- lastSyncTime: '2026-01-01T00:00:00.000Z', -- }, -- })), -- saveState: vi.fn(async () => {}), -- writeCommitInfo: vi.fn(async () => {}), -- addRepoToGroup: vi.fn(async () => false), -- syncGroupByName: vi.fn(async () => {}), -- getAvailableMemoryGB: vi.fn(() => 8), -- }); -- -- const result = await runAutoSyncOnce(config, { -- deps, -- logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, -- now: () => new Date('2026-06-30T00:00:00.000Z'), -- }); -- -- expect(result.analyzed).toBe(1); -- expect(deps.saveState).toHaveBeenCalledWith( -- expect.objectContaining({ -- '/tmp/repos/gitee.com/qts_server/qts_account|master': expect.objectContaining({ -- analyzeConsecutiveFailures: 0, -- lastAnalyzeError: undefined, -- lastAnalyzeStatus: 'success', -- }), -- }), -- ); -- }); -- -- it('resolves actual concurrency from configured value and memory', () => { -- expect(resolveActualConcurrency(8, 10)).toBe(5); -- expect(resolveActualConcurrency(8, 1)).toBe(1); -- expect(resolveActualConcurrency(2, 10)).toBe(2); -- }); -- -- it('detects existing groupPath to registryName mappings as already joined', async () => { -- const previousHome = process.env.GITNEXUS_HOME; -- const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-group-')); -- try { -- process.env.GITNEXUS_HOME = tempDir; -- const groupDir = path.join(tempDir, 'groups', 'back_end'); -- await fs.mkdir(groupDir, { recursive: true }); -- await fs.writeFile( -- path.join(groupDir, 'group.yaml'), -- ['version: 1', 'name: back_end', 'repos:', ' hr/hiring/backend: qts_account'].join('\n'), -- ); -- -- await expect( -- addRepoToGroup({ groupName: 'back_end' }, 'hr/hiring/backend', 'qts_account'), -- ).resolves.toBe(false); -- -- await expect(fs.readFile(path.join(groupDir, 'group.yaml'), 'utf-8')).resolves.toContain( -- 'hr/hiring/backend: qts_account', -- ); -- } finally { -- if (previousHome === undefined) delete process.env.GITNEXUS_HOME; -- else process.env.GITNEXUS_HOME = previousHome; -- await fs.rm(tempDir, { recursive: true, force: true }); -- } -- }); --}); -- --describe('auto-sync starter', () => { -- it('registers a clearable timer with a valid fixed config', async () => { -- const previousHome = process.env.GITNEXUS_HOME; -- const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-starter-')); -- const timer = { unref: vi.fn() }; -- const setIntervalFn = vi.fn(() => timer) as unknown as typeof setInterval; -- const clearIntervalFn = vi.fn() as unknown as typeof clearInterval; -- const runOnce = vi.fn(async () => ({ synced: 0, analyzed: 0, skippedAnalysis: 0, failed: 0 })); -- const stderr = { write: vi.fn() }; -- -- try { -- process.env.GITNEXUS_HOME = tempDir; -- await fs.writeFile( -- path.join(tempDir, 'watch_config.yml'), -- [ -- 'sync_interval_minutes: 5', -- 'projects:', -- ' - local_path: /tmp/repos', -- ' group_name: back_end', -- ' branch: master', -- ' remote_urls:', -- ' - git@gitee.com:qts_server/qts_account.git', -- ].join('\n'), -- ); -- -- const handle = await startAutoSyncWatch({ -- setIntervalFn, -- clearIntervalFn, -- runOnce, -- stderr, -- keepAlive: false, -- deps: { isProcessAlive: vi.fn(() => false) }, -- }); -- -- expect(handle).not.toBeNull(); -- expect(runOnce).toHaveBeenCalledTimes(1); -- expect(setIntervalFn).toHaveBeenCalledWith(expect.any(Function), 300_000); -- expect(timer.unref).toHaveBeenCalled(); -- await vi.waitFor(() => { -- expect(stderr.write).toHaveBeenCalledWith( -- expect.stringContaining('[auto-sync] Watch loop started at '), -- ); -- expect(stderr.write).toHaveBeenCalledWith( -- '[auto-sync] Watch loop finished: synced=0 analyzed=0 skipped=0 failed=0.\n', -- ); -- }); -- -- await handle?.stop(); -- -- expect(clearIntervalFn).toHaveBeenCalledWith(timer); -- } finally { -- if (previousHome === undefined) delete process.env.GITNEXUS_HOME; -- else process.env.GITNEXUS_HOME = previousHome; -- await fs.rm(tempDir, { recursive: true, force: true }); -- } -- }); -- -- it('skips overlapping scheduled runs while a previous run is active', async () => { -- const previousHome = process.env.GITNEXUS_HOME; -- const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-starter-')); -- const timer = { unref: vi.fn() }; -- let scheduled: (() => void) | undefined; -- const setIntervalFn = vi.fn((fn: () => void) => { -- scheduled = fn; -- return timer; -- }) as unknown as typeof setInterval; -- const stderr = { write: vi.fn() }; -- const releaseRuns: Array<() => void> = []; -- const runOnce = vi.fn( -- () => -- new Promise((resolve) => { -- releaseRuns.push(() => -- resolve({ synced: 0, analyzed: 0, skippedAnalysis: 0, failed: 0 }), -- ); -- }), -- ); -- let handle: Awaited> | undefined; -- -- try { -- process.env.GITNEXUS_HOME = tempDir; -- await fs.writeFile( -- path.join(tempDir, 'watch_config.yml'), -- [ -- 'sync_interval_minutes: 5', -- 'projects:', -- ' - local_path: /tmp/repos', -- ' branch: master', -- ' remote_urls:', -- ' - git@github.com:team/repo.git', -- ].join('\n'), -- ); -- -- handle = await startAutoSyncWatch({ setIntervalFn, runOnce, stderr }); -- scheduled?.(); -- -- expect(runOnce).toHaveBeenCalledTimes(1); -- expect(stderr.write).toHaveBeenCalledWith( -- '[auto-sync] Previous run is still active; skipping overlapping run.\n', -- ); -- -- releaseRuns.shift()?.(); -- await new Promise((resolve) => setTimeout(resolve, 0)); -- scheduled?.(); -- -- expect(runOnce).toHaveBeenCalledTimes(2); -- releaseRuns.shift()?.(); -- await handle?.stop(); -- handle = undefined; -- } finally { -- releaseRuns.splice(0).forEach((release) => release()); -- await handle?.stop(); -- if (previousHome === undefined) delete process.env.GITNEXUS_HOME; -- else process.env.GITNEXUS_HOME = previousHome; -- await fs.rm(tempDir, { recursive: true, force: true }); -- } -- }); -- -- it('does not start a new run from a queued interval tick after stop', async () => { -- const previousHome = process.env.GITNEXUS_HOME; -- const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-starter-')); -- const timer = { unref: vi.fn() }; -- let scheduled: (() => void) | undefined; -- const setIntervalFn = vi.fn((fn: () => void) => { -- scheduled = fn; -- return timer; -- }) as unknown as typeof setInterval; -- const stderr = { write: vi.fn() }; -- let releaseRun: (() => void) | undefined; -- const runOnce = vi.fn( -- () => -- new Promise((resolve) => { -- releaseRun = () => resolve({ synced: 0, analyzed: 0, skippedAnalysis: 0, failed: 0 }); -- }), -- ); -- let handle: Awaited> | undefined; -- -- try { -- process.env.GITNEXUS_HOME = tempDir; -- await fs.writeFile( -- path.join(tempDir, 'watch_config.yml'), -- [ -- 'sync_interval_minutes: 5', -- 'projects:', -- ' - local_path: /tmp/repos', -- ' branch: master', -- ' remote_urls:', -- ' - git@github.com:team/repo.git', -- ].join('\n'), -- ); -- -- handle = await startAutoSyncWatch({ setIntervalFn, runOnce, stderr }); -- expect(runOnce).toHaveBeenCalledTimes(1); -- -- const stopping = handle!.stop(); -- releaseRun?.(); -- await stopping; -- handle = undefined; -- scheduled?.(); -- -- expect(runOnce).toHaveBeenCalledTimes(1); -- } finally { -- releaseRun?.(); -- await handle?.stop(); -- if (previousHome === undefined) delete process.env.GITNEXUS_HOME; -- else process.env.GITNEXUS_HOME = previousHome; -- await fs.rm(tempDir, { recursive: true, force: true }); -- } -- }); -- -- it('cancels the active run before removing watch ownership files', async () => { -- const previousHome = process.env.GITNEXUS_HOME; -- const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-starter-')); -- const cancelled = vi.fn(); -- const runOnce = vi.fn( -- (_config, options) => -- new Promise((resolve) => { -- options?.signal?.addEventListener( -- 'abort', -- () => { -- cancelled(); -- resolve({ synced: 0, analyzed: 0, skippedAnalysis: 0, failed: 0 }); -- }, -- { once: true }, -- ); -- }), -- ); -- try { -- process.env.GITNEXUS_HOME = tempDir; -- await fs.writeFile( -- path.join(tempDir, 'watch_config.yml'), -- [ -- 'sync_interval_minutes: 5', -- 'projects:', -- ' - local_path: /tmp/repos', -- ' branch: master', -- ' remote_urls:', -- ' - git@github.com:team/repo.git', -- ].join('\n'), -- ); -- const handle = await startAutoSyncWatch({ -- runOnce, -- keepAlive: false, -- deps: { isProcessAlive: vi.fn(() => false) }, -- }); -- const paths = getAutoSyncWatchPaths(tempDir); -- await handle!.stop(); -- -- expect(cancelled).toHaveBeenCalledTimes(1); -- await expect(fs.access(paths.pidPath)).rejects.toThrow(); -- await expect(fs.access(paths.ownerPath)).rejects.toThrow(); -- } finally { -- if (previousHome === undefined) delete process.env.GITNEXUS_HOME; -- else process.env.GITNEXUS_HOME = previousHome; -- await fs.rm(tempDir, { recursive: true, force: true }); -- } -- }); -- -- it('refuses a second running watch for the same GITNEXUS_HOME', async () => { -- const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-watch-')); -- const paths = getAutoSyncWatchPaths(tempDir); -- const stderr = { write: vi.fn() }; -- try { -- await writeWatchOwner(paths, 12345); -- const handle = await startAutoSyncWatch({ -- paths, -- stderr, -- deps: { -- isProcessAlive: vi.fn(() => true), -- readProcessCommand: vi.fn(() => verifiedWatchCommand), -- readProcessStartTime: vi.fn(() => verifiedProcessStartTime), -- }, -- }); -- -- expect(handle).toBeNull(); -- expect(stderr.write).toHaveBeenCalledWith( -- '[auto-sync] Watch is already running with pid 12345.\n', -- ); -- } finally { -- await fs.rm(tempDir, { recursive: true, force: true }); -- } -- }); -- -- it('recovers an abandoned watch mutex after the owner exits', async () => { -- const previousHome = process.env.GITNEXUS_HOME; -- const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-watch-')); -- const paths = getAutoSyncWatchPaths(tempDir); -- const stderr = { write: vi.fn() }; -- try { -- process.env.GITNEXUS_HOME = tempDir; -- await writeWatchOwner(paths, 12345, 'abandoned-owner'); -- await fs.writeFile( -- path.join(tempDir, 'watch_config.yml'), -- [ -- 'sync_interval_minutes: 5', -- 'projects:', -- ' - local_path: /tmp/repos', -- ' branch: master', -- ' remote_urls:', -- ' - git@github.com:team/repo.git', -- ].join('\n'), -- ); -- -- const handle = await startAutoSyncWatch({ -- paths, -- stderr, -- runOnce: vi.fn(async () => ({ synced: 0, analyzed: 0, skippedAnalysis: 0, failed: 0 })), -- keepAlive: false, -- deps: { isProcessAlive: vi.fn(() => false) }, -- }); -- -- expect(handle).not.toBeNull(); -- expect(await fs.readFile(paths.pidPath, 'utf-8')).toBe(`${process.pid}\n`); -- expect(await fs.readFile(paths.ownerPath, 'utf-8')).not.toContain('abandoned-owner'); -- await handle?.stop(); -- await expect(fs.access(paths.mutexPath)).rejects.toThrow(); -- } finally { -- if (previousHome === undefined) delete process.env.GITNEXUS_HOME; -- else process.env.GITNEXUS_HOME = previousHome; -- await fs.rm(tempDir, { recursive: true, force: true }); -- } -- }); -- -- it('does not delete a half-initialized lease when pid has not been written yet', async () => { -- const previousHome = process.env.GITNEXUS_HOME; -- const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-watch-')); -- const paths = getAutoSyncWatchPaths(tempDir); -- const stderr = { write: vi.fn() }; -- try { -- process.env.GITNEXUS_HOME = tempDir; -- await fs.mkdir(path.dirname(paths.pidPath), { recursive: true }); -- await fs.mkdir(paths.mutexPath); -- await fs.writeFile( -- paths.ownerPath, -- `${JSON.stringify({ pid: 12345, ownerId: 'starting-owner', processStartTime: verifiedProcessStartTime, createdAt: '2026-06-30T00:00:00.000Z' })}\n`, -- ); -- await fs.writeFile( -- path.join(tempDir, 'watch_config.yml'), -- [ -- 'sync_interval_minutes: 5', -- 'projects:', -- ' - local_path: /tmp/repos', -- ' branch: master', -- ' remote_urls:', -- ' - git@github.com:team/repo.git', -- ].join('\n'), -- ); -- -- const handle = await startAutoSyncWatch({ -- paths, -- stderr, -- runOnce: vi.fn(async () => ({ synced: 0, analyzed: 0, skippedAnalysis: 0, failed: 0 })), -- deps: { -- isProcessAlive: vi.fn(() => true), -- readProcessCommand: vi.fn(() => verifiedWatchCommand), -- readProcessStartTime: vi.fn(() => verifiedProcessStartTime), -- }, -- }); -- -- expect(handle).toBeNull(); -- expect(stderr.write).toHaveBeenCalledWith( -- '[auto-sync] Watch is already running with pid 12345.\n', -- ); -- expect(await fs.readFile(paths.ownerPath, 'utf-8')).toContain('starting-owner'); -- await expect(fs.access(paths.mutexPath)).resolves.toBeUndefined(); -- await expect(fs.access(paths.pidPath)).rejects.toThrow(); -- } finally { -- if (previousHome === undefined) delete process.env.GITNEXUS_HOME; -- else process.env.GITNEXUS_HOME = previousHome; -- await fs.rm(tempDir, { recursive: true, force: true }); -- } -- }); -- -- it('does not delete a live half-initialized lease when stop runs before pid is written', async () => { -- const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-watch-')); -- const paths = getAutoSyncWatchPaths(tempDir); -- const stderr = { write: vi.fn() }; -- try { -- await fs.mkdir(path.dirname(paths.pidPath), { recursive: true }); -- await fs.mkdir(paths.mutexPath); -- await fs.writeFile( -- paths.ownerPath, -- `${JSON.stringify({ pid: 12345, ownerId: 'starting-owner', processStartTime: verifiedProcessStartTime, createdAt: '2026-06-30T00:00:00.000Z' })}\n`, -- ); -- -- await expect( -- stopAutoSyncWatch({ -- paths, -- stderr, -- deps: { isProcessAlive: vi.fn(() => true) }, -- }), -- ).resolves.toBe('refused'); -- -- expect(stderr.write).toHaveBeenCalledWith( -- '[auto-sync] Watch appears to be starting with pid 12345; pid file is not ready.\n', -- ); -- expect(await fs.readFile(paths.ownerPath, 'utf-8')).toContain('starting-owner'); -- await expect(fs.access(paths.mutexPath)).resolves.toBeUndefined(); -- await expect(fs.access(paths.statusPath)).rejects.toThrow(); -- } finally { -- await fs.rm(tempDir, { recursive: true, force: true }); -- } -- }); -- -- it('does not delete a stale half-initialized lease from the stopper', async () => { -- const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-watch-')); -- const paths = getAutoSyncWatchPaths(tempDir); -- try { -- await fs.mkdir(path.dirname(paths.pidPath), { recursive: true }); -- await fs.mkdir(paths.mutexPath); -- await fs.writeFile( -- paths.ownerPath, -- `${JSON.stringify({ pid: 12345, ownerId: 'stale-owner', processStartTime: verifiedProcessStartTime, createdAt: '2026-06-30T00:00:00.000Z' })}\n`, -- ); -- -- await expect( -- stopAutoSyncWatch({ -- paths, -- stderr: { write: vi.fn() }, -- deps: { isProcessAlive: vi.fn(() => false) }, -- }), -- ).resolves.toBe('refused'); -- -- expect(await fs.readFile(paths.ownerPath, 'utf-8')).toContain('stale-owner'); -- await expect(fs.access(paths.mutexPath)).resolves.toBeUndefined(); -- await expect(fs.access(paths.statusPath)).rejects.toThrow(); -- } finally { -- await fs.rm(tempDir, { recursive: true, force: true }); -- } -- }); -- -- it('reports not_running when no watch lease exists', async () => { -- const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-watch-')); -- try { -- await expect( -- stopAutoSyncWatch({ -- paths: getAutoSyncWatchPaths(tempDir), -- stderr: { write: vi.fn() }, -- }), -- ).resolves.toBe('not_running'); -- } finally { -- await fs.rm(tempDir, { recursive: true, force: true }); -- } -- }); -- -- it('writes an owner-fenced stop request without deleting a live watch lease', async () => { -- const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-watch-')); -- const paths = getAutoSyncWatchPaths(tempDir); -- try { -- const ownerId = await writeWatchOwner(paths, 12345, 'verified-owner'); -- -- await expect( -- stopAutoSyncWatch({ -- paths, -- timeoutMs: 0, -- stderr: { write: vi.fn() }, -- deps: { -- isProcessAlive: vi.fn(() => true), -- readProcessCommand: vi.fn(() => verifiedWatchCommand), -- readProcessStartTime: vi.fn(() => verifiedProcessStartTime), -- }, -- }), -- ).resolves.toBe('timeout'); -- -- expect( -- JSON.parse( -- await fs.readFile( -- path.join(path.dirname(paths.pidPath), `watch.stop.${ownerId}.json`), -- 'utf-8', -- ), -- ), -- ).toMatchObject({ -- pid: 12345, -- ownerId, -- processStartTime: verifiedProcessStartTime, -- requestedAt: expect.any(String), -- }); -- await expect(fs.readFile(paths.pidPath, 'utf-8')).resolves.toBe('12345\n'); -- await expect(fs.access(paths.ownerPath)).resolves.toBeUndefined(); -- await expect(fs.access(paths.mutexPath)).resolves.toBeUndefined(); -- } finally { -- await fs.rm(tempDir, { recursive: true, force: true }); -- } -- }); -- -- it('refuses to request stop for a reused pid', async () => { -- const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-watch-')); -- const paths = getAutoSyncWatchPaths(tempDir); -- try { -- await writeWatchOwner(paths, 12345); -- -- await expect( -- stopAutoSyncWatch({ -- paths, -- stderr: { write: vi.fn() }, -- deps: { -- isProcessAlive: vi.fn(() => true), -- readProcessCommand: vi.fn(() => 'node unrelated-service.js'), -- readProcessStartTime: vi.fn(() => verifiedProcessStartTime), -- }, -- }), -- ).resolves.toBe('refused'); -- -- await expect( -- readAutoSyncWatchStatus(paths, { -- isProcessAlive: vi.fn(() => true), -- readProcessCommand: vi.fn(() => 'node unrelated-service.js'), -- readProcessStartTime: vi.fn(() => verifiedProcessStartTime), -- }), -- ).resolves.toMatchObject({ -- state: 'error', -- pid: 12345, -- message: expect.stringContaining('not a GitNexus auto-sync process'), -- updatedAt: '2026-06-30T00:00:00.000Z', -- }); -- await expect(fs.readdir(path.dirname(paths.pidPath))).resolves.not.toContainEqual( -- expect.stringMatching(/^watch\.stop\./), -- ); -- } finally { -- await fs.rm(tempDir, { recursive: true, force: true }); -- } -- }); -- -- it('ignores a tampered ownerId that would escape the watch directory', async () => { -- const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-watch-')); -- const paths = getAutoSyncWatchPaths(tempDir); -- try { -- await writeWatchOwner(paths, 12345, '../../victim'); -- await expect( -- stopAutoSyncWatch({ -- paths, -- stderr: { write: vi.fn() }, -- deps: { -- isProcessAlive: vi.fn(() => true), -- readProcessCommand: vi.fn(() => verifiedWatchCommand), -- readProcessStartTime: vi.fn(() => verifiedProcessStartTime), -- }, -- }), -- ).resolves.toBe('refused'); -- await expect(fs.readdir(path.dirname(paths.pidPath))).resolves.not.toContainEqual( -- expect.stringMatching(/victim/), -- ); -- } finally { -- await fs.rm(tempDir, { recursive: true, force: true }); -- } -- }); -- -- it('does not trust a stored error status for an unverified live pid', async () => { -- const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-watch-')); -- const paths = getAutoSyncWatchPaths(tempDir); -- try { -- const ownerId = await writeWatchOwner(paths, 12345); -- await fs.writeFile( -- paths.statusPath, -- `${JSON.stringify({ -- state: 'error', -- pid: 12345, -- ownerId, -- message: 'stale stored failure', -- updatedAt: '2026-06-30T00:00:00.000Z', -- })}\n`, -- ); -- -- await expect( -- readAutoSyncWatchStatus(paths, { -- isProcessAlive: vi.fn(() => true), -- readProcessCommand: vi.fn(() => 'node unrelated-service.js'), -- readProcessStartTime: vi.fn(() => verifiedProcessStartTime), -- }), -- ).resolves.toMatchObject({ -- state: 'error', -- pid: 12345, -- message: expect.stringContaining('not a GitNexus auto-sync process'), -- updatedAt: '2026-06-30T00:00:00.000Z', -- }); -- } finally { -- await fs.rm(tempDir, { recursive: true, force: true }); -- } -- }); -- -- it('preserves stored updatedAt when the watch pid is stale', async () => { -- const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-watch-')); -- const paths = getAutoSyncWatchPaths(tempDir); -- try { -- await writeWatchOwner(paths, 12345); -- await expect( -- readAutoSyncWatchStatus(paths, { -- isProcessAlive: vi.fn(() => false), -- }), -- ).resolves.toMatchObject({ -- state: 'stale', -- pid: 12345, -- updatedAt: '2026-06-30T00:00:00.000Z', -- }); -- } finally { -- await fs.rm(tempDir, { recursive: true, force: true }); -- } -- }); -- -- it('stops a watch only when its own owner-fenced request is polled', async () => { -- const previousHome = process.env.GITNEXUS_HOME; -- const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-watch-')); -- const paths = getAutoSyncWatchPaths(tempDir); -- const callbacks: Array<() => void> = []; -- const timer = { unref: vi.fn() }; -- try { -- process.env.GITNEXUS_HOME = tempDir; -- await fs.writeFile( -- path.join(tempDir, 'watch_config.yml'), -- [ -- 'sync_interval_minutes: 5', -- 'projects:', -- ' - local_path: /tmp/repos', -- ' branch: master', -- ' remote_urls:', -- ' - git@github.com:team/repo.git', -- ].join('\n'), -- ); -- const handle = await startAutoSyncWatch({ -- paths, -- keepAlive: false, -- setIntervalFn: vi.fn((callback: () => void) => { -- callbacks.push(callback); -- return timer; -- }) as unknown as typeof setInterval, -- clearIntervalFn: vi.fn() as unknown as typeof clearInterval, -- runOnce: vi.fn(async () => ({ synced: 0, analyzed: 0, skippedAnalysis: 0, failed: 0 })), -- deps: { readProcessStartTime: vi.fn(() => verifiedProcessStartTime) }, -- }); -- expect(handle).not.toBeNull(); -- const owner = JSON.parse(await fs.readFile(paths.ownerPath, 'utf-8')); -- await fs.writeFile( -- path.join(path.dirname(paths.pidPath), `watch.stop.${owner.ownerId}.json`), -- `${JSON.stringify({ -- pid: process.pid, -- ownerId: owner.ownerId, -- processStartTime: verifiedProcessStartTime, -- requestedAt: new Date().toISOString(), -- })}\n`, -- ); -- -- callbacks[0]!(); -- await vi.waitFor(async () => expect(fs.access(paths.pidPath)).rejects.toThrow()); -- await expect(fs.access(paths.ownerPath)).rejects.toThrow(); -- await expect(fs.access(paths.mutexPath)).rejects.toThrow(); -- } finally { -- if (previousHome === undefined) delete process.env.GITNEXUS_HOME; -- else process.env.GITNEXUS_HOME = previousHome; -- await fs.rm(tempDir, { recursive: true, force: true }); -- } -- }); -- -- it('reports cancelling until a timed-out analysis run settles', async () => { -- const previousHome = process.env.GITNEXUS_HOME; -- const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-watch-')); -- const paths = getAutoSyncWatchPaths(tempDir); -- let releaseRun!: () => void; -- let requestCancellation!: () => void; -- try { -- process.env.GITNEXUS_HOME = tempDir; -- await fs.writeFile( -- path.join(tempDir, 'watch_config.yml'), -- [ -- 'sync_interval_minutes: 5', -- 'projects:', -- ' - local_path: /tmp/repos', -- ' branch: master', -- ' remote_urls:', -- ' - git@github.com:team/repo.git', -- ].join('\n'), -- ); -- const handle = await startAutoSyncWatch({ -- paths, -- keepAlive: false, -- runOnce: vi.fn( -- (_config, options) => -- new Promise((resolve) => { -- requestCancellation = options.onAnalysisCancellationRequested; -- releaseRun = () => resolve({ synced: 0, analyzed: 0, skippedAnalysis: 0, failed: 0 }); -- }), -- ), -- deps: { readProcessStartTime: vi.fn(() => verifiedProcessStartTime) }, -- }); -- requestCancellation(); -- await vi.waitFor(async () => { -- await expect( -- readAutoSyncWatchStatus(paths, { -- isProcessAlive: vi.fn(() => true), -- readProcessCommand: vi.fn(() => verifiedWatchCommand), -- readProcessStartTime: vi.fn(() => verifiedProcessStartTime), -- }), -- ).resolves.toMatchObject({ state: 'cancelling' }); -- }); -- -- releaseRun(); -- await vi.waitFor(async () => { -- await expect( -- readAutoSyncWatchStatus(paths, { -- isProcessAlive: vi.fn(() => true), -- readProcessCommand: vi.fn(() => verifiedWatchCommand), -- readProcessStartTime: vi.fn(() => verifiedProcessStartTime), -- }), -- ).resolves.toMatchObject({ state: 'running' }); -- }); -- await handle?.stop(); -- } finally { -- if (previousHome === undefined) delete process.env.GITNEXUS_HOME; -- else process.env.GITNEXUS_HOME = previousHome; -- await fs.rm(tempDir, { recursive: true, force: true }); -- } -- }); -- -- it('keeps watch ownership while stop waits for an active run to settle', async () => { -- const previousHome = process.env.GITNEXUS_HOME; -- const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-watch-')); -- const paths = getAutoSyncWatchPaths(tempDir); -- let releaseRun!: () => void; -- try { -- process.env.GITNEXUS_HOME = tempDir; -- await fs.writeFile( -- path.join(tempDir, 'watch_config.yml'), -- [ -- 'sync_interval_minutes: 5', -- 'projects:', -- ' - local_path: /tmp/repos', -- ' branch: master', -- ' remote_urls:', -- ' - git@github.com:team/repo.git', -- ].join('\n'), -- ); -- const handle = await startAutoSyncWatch({ -- paths, -- keepAlive: false, -- runOnce: vi.fn( -- () => -- new Promise((resolve) => { -- releaseRun = () => resolve({ synced: 0, analyzed: 0, skippedAnalysis: 0, failed: 0 }); -- }), -- ), -- deps: { readProcessStartTime: vi.fn(() => verifiedProcessStartTime) }, -- }); -- const stopping = handle!.stop(); -- -- await vi.waitFor(async () => { -- await expect( -- readAutoSyncWatchStatus(paths, { -- isProcessAlive: vi.fn(() => true), -- readProcessCommand: vi.fn(() => verifiedWatchCommand), -- readProcessStartTime: vi.fn(() => verifiedProcessStartTime), -- }), -- ).resolves.toMatchObject({ state: 'stopping' }); -- }); -- await expect(fs.access(paths.pidPath)).resolves.toBeUndefined(); -- await expect(fs.access(paths.ownerPath)).resolves.toBeUndefined(); -- await expect(fs.access(paths.mutexPath)).resolves.toBeUndefined(); -- -- releaseRun(); -- await stopping; -- await expect(fs.access(paths.pidPath)).rejects.toThrow(); -- await expect(fs.access(paths.ownerPath)).rejects.toThrow(); -- await expect(fs.access(paths.mutexPath)).rejects.toThrow(); -- } finally { -- if (previousHome === undefined) delete process.env.GITNEXUS_HOME; -- else process.env.GITNEXUS_HOME = previousHome; -- await fs.rm(tempDir, { recursive: true, force: true }); -- } -- }); --}); -diff --git a/gitnexus/test/unit/auto-sync.test.ts b/gitnexus/test/unit/auto-sync.test.ts -deleted file mode 100644 -index 1b0110023..000000000 ---- a/gitnexus/test/unit/auto-sync.test.ts -+++ /dev/null -@@ -1,730 +0,0 @@ --import fs from 'node:fs/promises'; --import os from 'node:os'; --import path from 'node:path'; --import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -- --import { -- extractRepoNameFromRemoteUrl, -- getAutoSyncMutexPath, -- getAutoSyncStatePath, -- getAutoSyncWatchDir, -- getProjectCommitInfoPath, -- loadAutoSyncConfig, -- parseAutoSyncConfig, -- parseBranchCandidates, -- parseDurationMs, -- quarantineAutoSyncPartial, -- resolveConfiguredCloneRoot, -- loadAutoSyncState, -- resetAutoSyncState, -- saveAutoSyncState, -- shouldAnalyzeCommit, -- validateAutoSyncRemoteUrl, -- validateAutoSyncBranchName, -- writeProjectCommitInfo, --} from '../../src/core/auto-sync/index.js'; --import { acquireFileLock } from '../../src/storage/file-lock.js'; -- --describe('auto-sync', () => { -- let tempDir: string; -- let gitnexusHome: string; -- let oldHome: string | undefined; -- -- beforeEach(async () => { -- const base = path.join(process.cwd(), '.tmp-test'); -- await fs.mkdir(base, { recursive: true }); -- tempDir = await fs.realpath(await fs.mkdtemp(path.join(base, 'gitnexus-auto-sync-'))); -- gitnexusHome = path.join(tempDir, '.gitnexus'); -- await fs.mkdir(gitnexusHome); -- oldHome = process.env.GITNEXUS_HOME; -- process.env.GITNEXUS_HOME = gitnexusHome; -- }); -- -- afterEach(async () => { -- if (oldHome === undefined) delete process.env.GITNEXUS_HOME; -- else process.env.GITNEXUS_HOME = oldHome; -- await fs.rm(tempDir, { recursive: true, force: true }); -- vi.restoreAllMocks(); -- }); -- -- it('places watch runtime artifacts under the watch directory by default', () => { -- expect(getAutoSyncWatchDir(gitnexusHome)).toBe(path.join(gitnexusHome, 'watch')); -- expect(getAutoSyncMutexPath(gitnexusHome)).toBe( -- path.join(gitnexusHome, 'watch', 'watch.mutex'), -- ); -- expect(getAutoSyncStatePath(gitnexusHome)).toBe( -- path.join(gitnexusHome, 'watch', 'auto-sync-state.json'), -- ); -- expect(getProjectCommitInfoPath(gitnexusHome)).toBe( -- path.join(gitnexusHome, 'watch', 'project_commit_info.txt'), -- ); -- }); -- -- it('refuses to reset state while the watch mutex is held', async () => { -- const statePath = getAutoSyncStatePath(gitnexusHome); -- const infoPath = getProjectCommitInfoPath(gitnexusHome); -- await fs.mkdir(path.dirname(statePath), { recursive: true }); -- await fs.writeFile(statePath, '{"kept":true}\n'); -- await fs.writeFile(infoPath, 'kept\n'); -- const release = await acquireFileLock(getAutoSyncMutexPath(gitnexusHome)); -- -- try { -- await expect(resetAutoSyncState(gitnexusHome)).resolves.toBe(false); -- await expect(fs.readFile(statePath, 'utf-8')).resolves.toContain('kept'); -- await expect(fs.readFile(infoPath, 'utf-8')).resolves.toBe('kept\n'); -- } finally { -- await release(); -- } -- }); -- -- it('resets derived state while holding the watch mutex', async () => { -- const statePath = getAutoSyncStatePath(gitnexusHome); -- const infoPath = getProjectCommitInfoPath(gitnexusHome); -- const mutexPath = getAutoSyncMutexPath(gitnexusHome); -- await fs.mkdir(path.dirname(statePath), { recursive: true }); -- await fs.writeFile(statePath, '{}\n'); -- await fs.writeFile(infoPath, 'derived\n'); -- -- await expect(resetAutoSyncState(gitnexusHome)).resolves.toBe(true); -- -- await expect(fs.access(statePath)).rejects.toThrow(); -- await expect(fs.access(infoPath)).rejects.toThrow(); -- await expect(fs.access(mutexPath)).rejects.toThrow(); -- }); -- -- it('loads watch_config.yml from GITNEXUS_HOME and normalizes branch candidates', async () => { -- await fs.writeFile( -- path.join(gitnexusHome, 'watch_config.yml'), -- [ -- 'sync_interval_minutes: 120', -- 'max_concurrency: 3', -- 'repo_git_timeout: 12s', -- 'analyze_timeout: 45m', -- 'analyze_failure_threshold: 2', -- 'projects:', -- ' - local_path: /tmp/repos', -- ' group_name: back_end', -- ' overwrite_local_changes: true', -- ' branches: [test, master, test]', -- ' remote_urls:', -- ' - git@gitee.com:qts_server/qts_account.git', -- ].join('\n'), -- ); -- -- const loaded = await loadAutoSyncConfig(); -- -- expect(loaded.ok).toBe(true); -- if (!loaded.ok) throw new Error('expected config to load'); -- expect(loaded.config.configPath).toBe(path.join(gitnexusHome, 'watch_config.yml')); -- expect(loaded.config.syncIntervalMinutes).toBe(120); -- expect(loaded.config.maxConcurrency).toBe(3); -- expect(loaded.config.repoGitTimeoutMs).toBe(12_000); -- expect(loaded.config.analyzeTimeoutMs).toBe(2_700_000); -- expect(loaded.config.analyzeFailureThreshold).toBe(2); -- expect(loaded.config.projects[0]).toMatchObject({ -- localPath: '/tmp/repos', -- groupName: 'back_end', -- overwriteLocalChanges: true, -- branches: ['test', 'master'], -- remoteUrls: ['git@gitee.com:qts_server/qts_account.git'], -- }); -- }); -- -- it('defaults repo_git_timeout and max_concurrency and allows empty group_name', async () => { -- await fs.writeFile( -- path.join(gitnexusHome, 'watch_config.yml'), -- [ -- 'sync_interval_minutes: 10', -- 'projects:', -- ' - local_path: /tmp/repos', -- ' group_name: ""', -- ' branch: master', -- ' remote_urls:', -- ' - git@github.com:owner/repo.git', -- ].join('\n'), -- ); -- -- const loaded = await loadAutoSyncConfig(); -- -- expect(loaded.ok).toBe(true); -- if (!loaded.ok) throw new Error('expected config'); -- expect(loaded.config.repoGitTimeoutMs).toBe(10_000); -- expect(loaded.config.analyzeTimeoutMs).toBe(300_000); -- expect(loaded.config.maxConcurrency).toBe(1); -- expect(loaded.config.analyzeFailureThreshold).toBe(3); -- expect(loaded.config.projects[0].groupName).toBeUndefined(); -- expect(loaded.config.projects[0].overwriteLocalChanges).toBe(false); -- }); -- -- it('rejects boolean max_concurrency instead of coercing it to 1', () => { -- expect(() => -- parseAutoSyncConfig( -- [ -- 'sync_interval_minutes: 10', -- 'max_concurrency: true', -- 'projects:', -- ' - local_path: /tmp/repos', -- ' branch: master', -- ' remote_urls:', -- ' - git@github.com:owner/repo.git', -- ].join('\n'), -- '/tmp/watch_config.yml', -- ), -- ).toThrow('max_concurrency must be a positive integer'); -- }); -- -- it('rejects repo_git_timeout values above the Node timer limit', () => { -- expect(() => -- parseAutoSyncConfig( -- [ -- 'sync_interval_minutes: 10', -- 'repo_git_timeout: 2147483648ms', -- 'projects:', -- ' - local_path: /tmp/repos', -- ' branch: main', -- ' remote_urls:', -- ' - https://github.com/owner/repo.git', -- ].join('\n'), -- '/tmp/watch_config.yml', -- ), -- ).toThrow('repo_git_timeout must not exceed 2147483647ms'); -- }); -- -- it('rejects a repo_git_timeout that exceeds the interval or an hour', () => { -- const config = (timeout: string) => -- [ -- 'sync_interval_minutes: 10', -- `repo_git_timeout: ${timeout}`, -- 'projects:', -- ' - local_path: /tmp/repos', -- ' branch: main', -- ' remote_urls:', -- ' - git@github.com:owner/repo.git', -- ].join('\n'); -- -- // A bare number means seconds, so this is ~7 days, not 10 minutes. -- expect(() => parseAutoSyncConfig(config('600000'), '/tmp/watch_config.yml')).toThrow( -- 'a bare number is interpreted as seconds', -- ); -- expect(() => parseAutoSyncConfig(config('600000ms'), '/tmp/watch_config.yml')).not.toThrow(); -- }); -- -- it('rejects analyze_timeout values above half the sync interval', async () => { -- await fs.writeFile( -- path.join(gitnexusHome, 'watch_config.yml'), -- [ -- 'sync_interval_minutes: 10', -- 'analyze_timeout: 6m', -- 'projects:', -- ' - local_path: /tmp/repos', -- ' branch: master', -- ' remote_urls:', -- ' - git@github.com:owner/repo.git', -- ].join('\n'), -- ); -- -- const loaded = await loadAutoSyncConfig(); -- -- expect(loaded.ok).toBe(false); -- if (loaded.ok) throw new Error('expected invalid config'); -- expect(loaded.message).toContain( -- 'analyze_timeout must not exceed half of sync_interval_minutes (5m)', -- ); -- }); -- -- it('rejects invalid analyze_failure_threshold values', async () => { -- await fs.writeFile( -- path.join(gitnexusHome, 'watch_config.yml'), -- [ -- 'sync_interval_minutes: 10', -- 'analyze_failure_threshold: 1', -- 'projects:', -- ' - local_path: /tmp/repos', -- ' branch: master', -- ' remote_urls:', -- ' - git@github.com:owner/repo.git', -- ].join('\n'), -- ); -- -- const loaded = await loadAutoSyncConfig(); -- -- expect(loaded.ok).toBe(false); -- if (loaded.ok) throw new Error('expected invalid config'); -- expect(loaded.message).toContain('analyze_failure_threshold must be an integer >= 2'); -- }); -- -- it('reports missing config without throwing', async () => { -- const loaded = await loadAutoSyncConfig(); -- -- expect(loaded).toEqual({ -- ok: false, -- reason: 'missing', -- message: `[auto-sync] Missing config file: ${path.join(gitnexusHome, 'watch_config.yml')}. Auto sync is skipped.`, -- }); -- }); -- -- it('reports invalid config without throwing', async () => { -- await fs.writeFile(path.join(gitnexusHome, 'watch_config.yml'), 'projects: []\n'); -- -- const loaded = await loadAutoSyncConfig(); -- -- expect(loaded.ok).toBe(false); -- if (loaded.ok) throw new Error('expected invalid config'); -- expect(loaded.reason).toBe('invalid'); -- expect(loaded.message).toContain('[auto-sync] Invalid watch_config.yml:'); -- expect(loaded.message).toContain('sync_interval_minutes must be a positive integer'); -- expect(loaded.message).toContain('projects must contain at least one project'); -- }); -- -- it('rejects missing, relative, and traversal local_path values at config load', async () => { -- await fs.writeFile( -- path.join(gitnexusHome, 'watch_config.yml'), -- [ -- 'sync_interval_minutes: 10', -- 'projects:', -- ' - local_path: ../repos', -- ' branch: master', -- ' remote_urls:', -- ' - git@github.com:team/repo.git', -- ].join('\n'), -- ); -- -- const loaded = await loadAutoSyncConfig(); -- -- expect(loaded.ok).toBe(false); -- if (loaded.ok) throw new Error('expected invalid config'); -- expect(loaded.message).toContain('local_path must be an absolute path'); -- }); -- -- it('hard-fails unsafe configured clone roots', async () => { -- await expect(resolveConfiguredCloneRoot('/')).rejects.toThrow('unsafe auto-sync clone root'); -- await expect(resolveConfiguredCloneRoot(os.homedir())).rejects.toThrow( -- 'unsafe auto-sync clone root', -- ); -- await expect( -- resolveConfiguredCloneRoot(path.join(await fs.realpath(os.tmpdir()), 'repos')), -- ).rejects.toThrow('unsafe auto-sync clone root'); -- const root = path.join(tempDir, 'repos'); -- await expect(resolveConfiguredCloneRoot(`${root}/../repos`)).rejects.toThrow('normalized'); -- }); -- -- it('rejects GitNexus internal directory descendants as clone roots', async () => { -- for (const internalDir of ['groups', 'indexes', 'quarantine']) { -- const root = path.join(gitnexusHome, internalDir, 'repo-root'); -- await fs.mkdir(root, { recursive: true }); -- -- await expect(resolveConfiguredCloneRoot(root)).rejects.toThrow('GitNexus internal directory'); -- } -- }); -- -- it('allows the default GitNexus repos directory as an auto-sync clone root', async () => { -- const root = path.join(gitnexusHome, 'repos'); -- await fs.mkdir(root, { recursive: true }); -- -- await expect(resolveConfiguredCloneRoot(root)).resolves.toEqual( -- expect.objectContaining({ -- root, -- quarantineRoot: path.join(gitnexusHome, 'watch', 'quarantine'), -- }), -- ); -- }); -- -- it.skipIf(process.platform === 'win32')( -- 'rejects symlinks in configured clone root paths', -- async () => { -- const realRoot = path.join(tempDir, 'real-root'); -- const linkRoot = path.join(tempDir, 'link-root'); -- await fs.mkdir(realRoot); -- await fs.symlink(realRoot, linkRoot); -- -- await expect(resolveConfiguredCloneRoot(linkRoot)).rejects.toThrow('symlink'); -- }, -- ); -- -- it('resolves safe configured clone roots and reports quarantine retention', async () => { -- const root = path.join(tempDir, 'repos'); -- await fs.mkdir(root); -- -- await expect(resolveConfiguredCloneRoot(root)).resolves.toEqual( -- expect.objectContaining({ -- root, -- quarantineRoot: path.join(gitnexusHome, 'watch', 'quarantine'), -- quarantineRetentionDays: 14, -- }), -- ); -- }); -- -- it('removes expired quarantine entries while preserving recent and unrelated files', async () => { -- const root = path.join(tempDir, 'repos'); -- const quarantineRoot = path.join(gitnexusHome, 'watch', 'quarantine'); -- const expired = path.join(quarantineRoot, 'auto-sync-expired-repo'); -- const recent = path.join(quarantineRoot, 'auto-sync-recent-repo'); -- const unrelated = path.join(quarantineRoot, 'operator-note.txt'); -- await fs.mkdir(expired, { recursive: true }); -- await fs.mkdir(recent); -- await fs.writeFile(unrelated, 'keep'); -- const old = new Date(Date.now() - 15 * 24 * 60 * 60 * 1_000); -- await fs.utimes(expired, old, old); -- -- await resolveConfiguredCloneRoot(root); -- -- await expect(fs.access(expired)).rejects.toThrow(); -- await expect(fs.access(recent)).resolves.toBeUndefined(); -- await expect(fs.readFile(unrelated, 'utf-8')).resolves.toBe('keep'); -- }); -- -- it('keeps only the newest quarantine entries per repository', async () => { -- const root = path.join(tempDir, 'repos'); -- const quarantineRoot = path.join(gitnexusHome, 'watch', 'quarantine'); -- await fs.mkdir(quarantineRoot, { recursive: true }); -- const uuid = '00000000-0000-4000-8000-000000000000'; -- const entryName = (stamp: string, repo: string) => `auto-sync-${stamp}-4242-${uuid}-${repo}`; -- // Seven ticks of the same failing repo; age alone would keep them all. -- const busy = ['01', '02', '03', '04', '05', '06', '07'].map((n) => -- entryName(`2026-08-2${n}T00-00-00-000Z`, 'busy-repo'), -- ); -- const quiet = ['01', '02'].map((n) => entryName(`2026-08-2${n}T00-00-00-000Z`, 'quiet-repo')); -- for (const name of [...busy, ...quiet]) { -- await fs.mkdir(path.join(quarantineRoot, name), { recursive: true }); -- await fs.writeFile(path.join(quarantineRoot, `${name}.README.txt`), 'note'); -- } -- -- await resolveConfiguredCloneRoot(root); -- -- const survivors = await fs.readdir(quarantineRoot); -- for (const name of busy.slice(-5)) { -- expect(survivors).toContain(name); -- expect(survivors).toContain(`${name}.README.txt`); -- } -- for (const name of busy.slice(0, 2)) { -- expect(survivors).not.toContain(name); -- expect(survivors).not.toContain(`${name}.README.txt`); -- } -- // A repo below the cap is untouched. -- for (const name of quiet) expect(survivors).toContain(name); -- }); -- -- it('falls back to copy and remove when quarantine crosses filesystems', async () => { -- const target = path.join(tempDir, 'partial-repo'); -- const quarantineRoot = path.join(gitnexusHome, 'watch', 'quarantine'); -- await fs.mkdir(target); -- await fs.writeFile(path.join(target, 'partial.txt'), 'partial'); -- vi.spyOn(fs, 'rename').mockRejectedValueOnce( -- Object.assign(new Error('cross-device link'), { code: 'EXDEV' }), -- ); -- -- const destination = await quarantineAutoSyncPartial(target, quarantineRoot); -- -- await expect(fs.readFile(path.join(destination, 'partial.txt'), 'utf-8')).resolves.toBe( -- 'partial', -- ); -- await expect(fs.access(target)).rejects.toThrow(); -- }); -- -- it('gives concurrent partial clone quarantines unique destinations', async () => { -- const quarantineRoot = path.join(gitnexusHome, 'watch', 'quarantine'); -- const first = path.join(tempDir, 'one', 'partial-repo'); -- const second = path.join(tempDir, 'two', 'partial-repo'); -- await Promise.all([ -- fs.mkdir(first, { recursive: true }), -- fs.mkdir(second, { recursive: true }), -- ]); -- -- const [firstDestination, secondDestination] = await Promise.all([ -- quarantineAutoSyncPartial(first, quarantineRoot), -- quarantineAutoSyncPartial(second, quarantineRoot), -- ]); -- -- expect(firstDestination).not.toBe(secondDestination); -- await expect(fs.access(firstDestination)).resolves.toBeUndefined(); -- await expect(fs.access(secondDestination)).resolves.toBeUndefined(); -- await expect(fs.access(first)).rejects.toThrow(); -- await expect(fs.access(second)).rejects.toThrow(); -- }); -- -- it('rejects group-writable configured clone roots', async () => { -- if (process.platform === 'win32') return; -- const root = path.join(tempDir, 'group-writable-repos'); -- await fs.mkdir(root, { mode: 0o770 }); -- await fs.chmod(root, 0o770); -- -- await expect(resolveConfiguredCloneRoot(root)).rejects.toThrow('group-writable'); -- }); -- -- it('rejects sticky world-writable configured clone roots', async () => { -- if (process.platform === 'win32') return; -- const root = path.join(tempDir, 'sticky-world-writable-repos'); -- await fs.mkdir(root); -- await fs.chmod(root, 0o1777); -- -- await expect(resolveConfiguredCloneRoot(root)).rejects.toThrow('world-writable'); -- }); -- -- it('creates missing configured clone roots before watch clone work', async () => { -- const root = path.join(tempDir, 'missing-repos'); -- -- await expect(resolveConfiguredCloneRoot(root)).resolves.toEqual( -- expect.objectContaining({ -- root, -- quarantineRoot: path.join(gitnexusHome, 'watch', 'quarantine'), -- }), -- ); -- expect((await fs.stat(root)).isDirectory()).toBe(true); -- }); -- -- it('parses branch strings and arrays with trimming and de-duplication', () => { -- expect(parseBranchCandidates('test, master, test')).toEqual(['test', 'master']); -- expect(parseBranchCandidates(['develop,master', 'develop'])).toEqual(['develop', 'master']); -- }); -- -- it('rejects unsafe auto-sync branch names', () => { -- expect(() => validateAutoSyncBranchName('feature/good-branch')).not.toThrow(); -- expect(() => validateAutoSyncBranchName('foo./bar')).toThrow('trailing-dot'); -- expect(() => validateAutoSyncBranchName('/main')).toThrow('must not start'); -- expect(() => validateAutoSyncBranchName('-upload-pack=evil')).toThrow('must not start'); -- expect(() => validateAutoSyncBranchName('feature bad')).toThrow('whitespace'); -- expect(() => validateAutoSyncBranchName('feature..bad')).toThrow('must not contain ".."'); -- expect(() => validateAutoSyncBranchName('bad:ref')).toThrow('not allowed'); -- expect(() => validateAutoSyncBranchName('feature.')).toThrow('must not end'); -- expect(() => validateAutoSyncBranchName('feature/')).toThrow('must not end'); -- expect(() => validateAutoSyncBranchName('feature//branch')).toThrow('consecutive'); -- expect(() => validateAutoSyncBranchName('feature@{x')).toThrow('must not contain "@{"'); -- expect(() => validateAutoSyncBranchName('.hidden')).toThrow('hidden'); -- expect(() => validateAutoSyncBranchName('foo/bar.lock')).toThrow('.lock'); -- }); -- -- it('extracts safe repository names from remote URLs', () => { -- expect(extractRepoNameFromRemoteUrl('git@gitee.com:qts_server/qts_account.git')).toBe( -- 'qts_account', -- ); -- expect(extractRepoNameFromRemoteUrl('git@gitlab.com:team/subgroup/repo-name.git')).toBe( -- 'repo-name', -- ); -- }); -- -- it('rejects unsafe repository names without sanitizing them', () => { -- // Rejected by the URL validator now, so the operator learns at config load -- // rather than once per tick from inside the sync loop. -- expect(() => extractRepoNameFromRemoteUrl('git@github.com:team/repo$name.git')).toThrow( -- 'repository name must use only', -- ); -- expect(() => extractRepoNameFromRemoteUrl('git@github.com:team/repo\\name.git')).toThrow( -- 'repository name must use only', -- ); -- expect(() => extractRepoNameFromRemoteUrl('git@github.com:team/..')).toThrow('traversal'); -- }); -- -- it('allows only github, gitlab, and gitee SSH SCP remote URLs', () => { -- expect(() => validateAutoSyncRemoteUrl('git@github.com:owner/repo')).not.toThrow(); -- expect(() => validateAutoSyncRemoteUrl('git@github.com:im-fan/multica.git')).not.toThrow(); -- expect(() => validateAutoSyncRemoteUrl('git@gitlab.com:group/subgroup/repo.git')).not.toThrow(); -- expect(() => -- validateAutoSyncRemoteUrl('git@gitee.com:qts-ops/qts-code-engineering.git'), -- ).not.toThrow(); -- expect(() => validateAutoSyncRemoteUrl('https://github.com/owner/repo.git')).toThrow( -- 'must use', -- ); -- expect(() => validateAutoSyncRemoteUrl('ssh://git@github.com/owner/repo.git')).toThrow( -- 'must use', -- ); -- expect(() => validateAutoSyncRemoteUrl('user@github.com:owner/repo.git')).toThrow('must use'); -- expect(() => validateAutoSyncRemoteUrl('git@example.com:owner/repo.git')).toThrow( -- 'host must be', -- ); -- // Traversal is a whole segment; consecutive dots inside a name are not. -- expect(() => validateAutoSyncRemoteUrl('git@github.com:owner/foo..bar.git')).not.toThrow(); -- expect(() => validateAutoSyncRemoteUrl('git@github.com:owner/../escape.git')).toThrow( -- 'traversal', -- ); -- // A separator smuggled into a segment is traversal on Windows even though -- // the segment is not literally `..`. -- expect(() => validateAutoSyncRemoteUrl(String.raw`git@github.com:..\..\outside/repo`)).toThrow( -- 'traversal', -- ); -- expect(() => validateAutoSyncRemoteUrl(String.raw`git@github.com:owner\..\..\x/repo`)).toThrow( -- 'traversal', -- ); -- expect(() => validateAutoSyncRemoteUrl('git@github.com:owner/repo.git?ref=main')).toThrow( -- 'must not include query strings or fragments', -- ); -- expect(() => validateAutoSyncRemoteUrl('git@github.com:owner/repo.git#main')).toThrow( -- 'must not include query strings or fragments', -- ); -- expect(() => validateAutoSyncRemoteUrl('git@github.com:owner/')).toThrow('path must include'); -- expect(() => validateAutoSyncRemoteUrl('git@github.com:owner//repo')).toThrow( -- 'path must include', -- ); -- }); -- -- it('parses repo git timeout durations', () => { -- expect(parseDurationMs('10s')).toBe(10_000); -- expect(parseDurationMs('2m')).toBe(120_000); -- expect(parseDurationMs('5000ms')).toBe(5000); -- expect(parseDurationMs('10')).toBe(10_000); -- expect(parseDurationMs(10)).toBe(10_000); -- }); -- -- it('keeps branch compatibility but rejects branch and branches together', async () => { -- await fs.writeFile( -- path.join(gitnexusHome, 'watch_config.yml'), -- [ -- 'sync_interval_minutes: 10', -- 'projects:', -- ' - local_path: /tmp/repos', -- ' branch: master', -- ' branches: [develop]', -- ' remote_urls:', -- ' - git@github.com:owner/repo.git', -- ].join('\n'), -- ); -- -- const loaded = await loadAutoSyncConfig(); -- -- expect(loaded.ok).toBe(false); -- if (loaded.ok) throw new Error('expected invalid config'); -- expect(loaded.message).toContain('must not set both branch and branches'); -- }); -- -- it('uses commit ids to skip unchanged analyses and retry failed prior analyses', () => { -- expect(shouldAnalyzeCommit({ currentCommit: 'abc', previousAnalyzedCommit: 'abc' })).toBe( -- false, -- ); -- expect( -- shouldAnalyzeCommit({ -- currentCommit: 'abc', -- previousAnalyzedCommit: 'abc', -- previousStatus: 'failed', -- }), -- ).toBe(true); -- expect(shouldAnalyzeCommit({ currentCommit: 'def', previousAnalyzedCommit: 'abc' })).toBe(true); -- }); -- -- it('saves state atomically and reloads it', async () => { -- const statePath = path.join(tempDir, 'auto-sync-state.json'); -- -- await saveAutoSyncState( -- { -- '/tmp/repos/qts_account|master': { -- codeCommitId: 'abc', -- analyzedCommitId: 'abc', -- lastAnalyzeStatus: 'success', -- analyzeConsecutiveFailures: 2, -- lastAnalyzeError: 'old error', -- lastSyncTime: '2026-06-30T00:00:00.000Z', -- }, -- }, -- statePath, -- ); -- -- await expect(fs.readdir(tempDir)).resolves.not.toContain( -- expect.stringContaining('auto-sync-state.json.tmp'), -- ); -- await expect(loadAutoSyncState(statePath)).resolves.toEqual({ -- '/tmp/repos/qts_account|master': { -- codeCommitId: 'abc', -- analyzedCommitId: 'abc', -- lastAnalyzeStatus: 'success', -- analyzeConsecutiveFailures: 2, -- lastAnalyzeError: 'old error', -- lastSyncTime: '2026-06-30T00:00:00.000Z', -- }, -- }); -- }); -- -- it('returns empty state and reports corrupt state files', async () => { -- const statePath = path.join(tempDir, 'auto-sync-state.json'); -- const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); -- await fs.writeFile(statePath, '{not-json', 'utf-8'); -- -- await expect(loadAutoSyncState(statePath)).resolves.toEqual({}); -- -- expect(stderr).toHaveBeenCalledWith( -- `[auto-sync] Ignoring corrupt state file: ${statePath}. State will be rebuilt.\n`, -- ); -- }); -- -- it('propagates an unreadable state file instead of overwriting it with empty state', async () => { -- // A directory stands in for any non-ENOENT read failure (EACCES, EIO). -- // Returning {} here would make the next tick persist empty state over -- // every repo's analyzed commit and failure counters. -- const statePath = path.join(tempDir, 'unreadable-state.json'); -- await fs.mkdir(statePath, { recursive: true }); -- -- await expect(loadAutoSyncState(statePath)).rejects.toThrow(); -- }); -- -- it('drops malformed state entries while preserving valid entries', async () => { -- const statePath = path.join(tempDir, 'auto-sync-state.json'); -- await fs.writeFile( -- statePath, -- JSON.stringify({ -- '/tmp/repos/valid|main': { -- codeCommitId: 'abc', -- analyzedCommitId: 'abc', -- lastAnalyzeStatus: 'success', -- analyzeConsecutiveFailures: 0, -- lastSyncTime: '2026-06-30T00:00:00.000Z', -- }, -- '/tmp/repos/invalid|main': { -- codeCommitId: 123, -- analyzeConsecutiveFailures: -1, -- lastSyncTime: null, -- }, -- }), -- ); -- -- await expect(loadAutoSyncState(statePath)).resolves.toEqual({ -- '/tmp/repos/valid|main': { -- codeCommitId: 'abc', -- analyzedCommitId: 'abc', -- lastAnalyzeStatus: 'success', -- analyzeConsecutiveFailures: 0, -- lastSyncTime: '2026-06-30T00:00:00.000Z', -- }, -- }); -- }); -- -- it('writes project_commit_info.txt atomically', async () => { -- const infoPath = path.join(tempDir, 'project_commit_info.txt'); -- -- await writeProjectCommitInfo( -- [ -- { -- remoteUrl: 'git@github.com:owner/repo.git', -- localPath: '/tmp/repos/repo', -- branch: 'master', -- codeCommitId: 'abc', -- analyzedCommitId: 'abc', -- status: 'success', -- analyzeConsecutiveFailures: 0, -- analyzeFailureThreshold: 3, -- lastSyncTime: '2026-06-30T00:00:00.000Z', -- }, -- { -- remoteUrl: 'git@github.com:owner/bad.git', -- localPath: '/tmp/repos/bad', -- branch: 'master', -- codeCommitId: 'def', -- analyzedCommitId: 'abc', -- status: 'threshold_skipped', -- analyzeConsecutiveFailures: 3, -- analyzeFailureThreshold: 3, -- lastAnalyzeError: 'parser crashed', -- lastSyncTime: '2026-06-30T00:00:00.000Z', -- }, -- ], -- infoPath, -- ); -- -- const content = await fs.readFile(infoPath, 'utf-8'); -- expect(content).toContain('remote: git@github.com:owner/repo.git'); -- expect(content).toContain('code_commit: abc'); -- expect(content).toContain('analyze_consecutive_failures: 0'); -- expect(content).toContain('analyze_failure_threshold: 3'); -- expect(content).toContain('status: threshold_skipped'); -- expect(content).toContain('last_analyze_error: parser crashed'); -- await expect(fs.readdir(tempDir)).resolves.not.toContain( -- expect.stringContaining('project_commit_info.txt.tmp'), -- ); -- }); --}); -diff --git a/gitnexus/test/unit/cli-index-help.test.ts b/gitnexus/test/unit/cli-index-help.test.ts -index e414c6cb6..a35541d18 100644 ---- a/gitnexus/test/unit/cli-index-help.test.ts -+++ b/gitnexus/test/unit/cli-index-help.test.ts -@@ -1,6 +1,5 @@ - import { spawnSync } from 'node:child_process'; - import fs from 'node:fs'; --import os from 'node:os'; - import path from 'node:path'; - import { fileURLToPath } from 'node:url'; - import { Command, Option } from 'commander'; -@@ -17,11 +16,7 @@ function runHelp(command: string, env: NodeJS.ProcessEnv = {}) { - } - - function runHelpArgs(args: string[], env: NodeJS.ProcessEnv = {}) { -- return runCliArgs([...args, '--help'], env); --} -- --function runCliArgs(args: string[], env: NodeJS.ProcessEnv = {}) { -- return spawnSync(process.execPath, [...CLI_SPAWN_PREFIX, ...args], { -+ return spawnSync(process.execPath, [...CLI_SPAWN_PREFIX, ...args, '--help'], { - cwd: repoRoot, - encoding: 'utf8', - env: { ...process.env, ...env }, -@@ -247,110 +242,6 @@ describe('CLI help surface', () => { - } - }); - -- it('auto-sync help exposes lifecycle actions and state files', () => { -- const result = runHelp('auto-sync'); -- -- expect(result.status).toBe(0); -- expect(result.stdout).toContain('gitnexus auto-sync [options] [action]'); -- expect(result.stdout).toContain('Actions: init, start (default), restart, stop, status, reset'); -- expect(result.stdout).toContain('GITNEXUS_HOME/watch_config.yml'); -- expect(result.stdout).toContain('GITNEXUS_HOME/watch/watch.pid'); -- expect(result.stdout).toContain('GITNEXUS_HOME/watch/project_commit_info.txt'); -- }); -- -- it('watch is reserved and does not start auto-sync or local watch', () => { -- const help = runHelp('watch'); -- expect(help.status).toBe(0); -- expect(help.stdout).toContain('gitnexus watch [options] [action]'); -- expect(help.stdout).toContain('gitnexus analyze --watch'); -- expect(help.stdout).toContain('gitnexus auto-sync start'); -- expect(help.stdout).not.toContain('GITNEXUS_HOME/watch_config.yml'); -- -- const started = runCliArgs(['watch'], {}); -- expect(started.status).toBe(1); -- expect(started.stderr).toContain('gitnexus watch'); -- expect(started.stderr).toContain('gitnexus analyze --watch'); -- expect(started.stderr).toContain('gitnexus auto-sync start'); -- -- const startAction = runCliArgs(['watch', 'start'], {}); -- expect(startAction.status).toBe(1); -- expect(startAction.stderr).toContain('gitnexus auto-sync start'); -- }); -- -- it('auto-sync init creates the default watch_config.yml and does not overwrite it', () => { -- const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-watch-init-')); -- try { -- const first = runCliArgs(['auto-sync', 'init'], { GITNEXUS_HOME: home }); -- const configPath = path.join(home, 'watch_config.yml'); -- -- expect(first.status).toBe(0); -- expect(first.stdout).toContain(`Created ${configPath}`); -- const config = fs.readFileSync(configPath, 'utf8'); -- expect(config).toContain('sync_interval_minutes: 10'); -- expect(config).toContain('analyze_failure_threshold: 3'); -- expect(config).toContain('analyze_timeout: 5m'); -- expect(config).toContain('overwrite_local_changes: false'); -- expect(config).toContain(`local_path: ${path.join(home, 'repos')}`); -- expect(config).not.toContain('/abs/path/to/repos'); -- expect(config).toContain('git@github.com:owner/repo.git'); -- expect(config).not.toContain('group_name:'); -- -- const second = runCliArgs(['auto-sync', 'init'], { GITNEXUS_HOME: home }); -- -- expect(second.status).toBe(1); -- expect(second.stderr).toContain(`Config already exists: ${configPath}`); -- expect(fs.readFileSync(configPath, 'utf8')).toBe(config); -- } finally { -- fs.rmSync(home, { recursive: true, force: true }); -- } -- }); -- -- it('auto-sync reset removes only derived auto-sync state files', () => { -- const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-watch-reset-')); -- const watchDir = path.join(home, 'watch'); -- const cloneMarker = path.join(home, 'repos', 'repo', 'keep.txt'); -- try { -- fs.mkdirSync(path.dirname(cloneMarker), { recursive: true }); -- fs.writeFileSync(cloneMarker, 'keep'); -- fs.mkdirSync(watchDir, { recursive: true }); -- fs.writeFileSync(path.join(watchDir, 'auto-sync-state.json'), '{}'); -- fs.writeFileSync(path.join(watchDir, 'project_commit_info.txt'), 'derived'); -- -- const result = runCliArgs(['auto-sync', 'reset'], { GITNEXUS_HOME: home }); -- -- expect(result.status).toBe(0); -- expect(result.stdout).toContain('Reset analysis state'); -- expect(fs.existsSync(path.join(watchDir, 'auto-sync-state.json'))).toBe(false); -- expect(fs.existsSync(path.join(watchDir, 'project_commit_info.txt'))).toBe(false); -- expect(fs.readFileSync(cloneMarker, 'utf8')).toBe('keep'); -- } finally { -- fs.rmSync(home, { recursive: true, force: true }); -- } -- }); -- -- it('auto-sync stop exits non-zero when no watch was stopped', () => { -- const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-watch-stop-')); -- try { -- const result = runCliArgs(['auto-sync', 'stop'], { GITNEXUS_HOME: home }); -- expect(result.status).toBe(1); -- expect(result.stderr).toContain('Watch is not running'); -- } finally { -- fs.rmSync(home, { recursive: true, force: true }); -- } -- }); -- -- it('auto-sync restart starts when the watch is not running', () => { -- const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-watch-restart-')); -- try { -- const result = runCliArgs(['auto-sync', 'restart'], { GITNEXUS_HOME: home }); -- expect(result.status).toBe(1); -- expect(result.stderr).toContain('Watch is not running'); -- expect(result.stderr).toContain('Missing config file'); -- } finally { -- fs.rmSync(home, { recursive: true, force: true }); -- } -- }); -- - it('wiki help shows provider, review, and verbose flags', () => { - const result = runHelp('wiki'); - -diff --git a/gitnexus/test/unit/file-lock.test.ts b/gitnexus/test/unit/file-lock.test.ts -deleted file mode 100644 -index 4f43ac778..000000000 ---- a/gitnexus/test/unit/file-lock.test.ts -+++ /dev/null -@@ -1,227 +0,0 @@ --import fs from 'node:fs/promises'; --import os from 'node:os'; --import path from 'node:path'; --import { setTimeout as sleep } from 'node:timers/promises'; --import { afterEach, describe, expect, it, vi } from 'vitest'; -- --import { acquireFileLock, FileLockBusyError } from '../../src/storage/file-lock.js'; -- --const tempDirs: string[] = []; -- --async function tempLockPath(): Promise { -- const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-file-lock-')); -- tempDirs.push(dir); -- return path.join(dir, 'locks', 'test.mutex'); --} -- --afterEach(async () => { -- await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); --}); -- --describe('file lock', () => { -- it('rejects a second holder for the same path', async () => { -- const lockPath = await tempLockPath(); -- const release = await acquireFileLock(lockPath); -- -- await expect(acquireFileLock(lockPath)).rejects.toBeInstanceOf(FileLockBusyError); -- -- await release(); -- }); -- -- it('propagates hard-link EPERM when no lock exists', async () => { -- const lockPath = await tempLockPath(); -- const error = Object.assign(new Error('hard links unavailable'), { code: 'EPERM' }); -- const link = vi.spyOn(fs, 'link').mockRejectedValueOnce(error); -- -- try { -- await expect(acquireFileLock(lockPath)).rejects.toBe(error); -- } finally { -- link.mockRestore(); -- } -- }); -- -- it('releases idempotently', async () => { -- const lockPath = await tempLockPath(); -- const release = await acquireFileLock(lockPath); -- -- await release(); -- await expect(release()).resolves.toBeUndefined(); -- const nextRelease = await acquireFileLock(lockPath); -- await nextRelease(); -- }); -- -- it('reclaims a lock whose owner process exited', async () => { -- const lockPath = await tempLockPath(); -- const oldRelease = await acquireFileLock(lockPath, { -- pid: 111, -- processStartTime: 'old-start', -- }); -- -- const nextRelease = await acquireFileLock(lockPath, { -- pid: 222, -- processStartTime: 'new-start', -- isProcessAlive: () => false, -- }); -- -- await oldRelease(); -- await expect( -- acquireFileLock(lockPath, { -- isProcessAlive: (pid) => pid === 222, -- readProcessStartTime: () => 'new-start', -- }), -- ).rejects.toBeInstanceOf(FileLockBusyError); -- await nextRelease(); -- }); -- -- it('reclaims a reused pid only when its start time differs', async () => { -- const lockPath = await tempLockPath(); -- await acquireFileLock(lockPath, { pid: 111, processStartTime: 'old-start' }); -- -- await expect( -- acquireFileLock(lockPath, { -- pid: 222, -- processStartTime: 'next-start', -- isProcessAlive: () => true, -- readProcessStartTime: () => 'old-start', -- }), -- ).rejects.toBeInstanceOf(FileLockBusyError); -- -- const nextRelease = await acquireFileLock(lockPath, { -- pid: 222, -- processStartTime: 'next-start', -- isProcessAlive: () => true, -- readProcessStartTime: () => 'reused-pid-start', -- }); -- -- await nextRelease(); -- }); -- -- it('never reclaims a lock held from another host', async () => { -- const lockPath = await tempLockPath(); -- await acquireFileLock(lockPath, { -- pid: 111, -- processStartTime: 'peer-start', -- hostname: 'peer-host', -- }); -- -- // Locally pid 111 is alive with a different start time, which is the -- // reuse signature — but the holder is on another machine, so this kernel -- // cannot judge it and the lock must stand. -- await expect( -- acquireFileLock(lockPath, { -- pid: 222, -- processStartTime: 'local-start', -- hostname: 'local-host', -- isProcessAlive: () => true, -- readProcessStartTime: () => 'unrelated-local-start', -- }), -- ).rejects.toBeInstanceOf(FileLockBusyError); -- }); -- -- it('fails closed for legacy or invalid lock contents without owner metadata', async () => { -- const lockPath = await tempLockPath(); -- const invalidContents = ['legacy lock', '{not json', JSON.stringify({ pid: 123 })]; -- await fs.mkdir(path.dirname(lockPath), { recursive: true }); -- -- for (const content of invalidContents) { -- await fs.writeFile(lockPath, content, 'utf-8'); -- await expect( -- acquireFileLock(lockPath, { pid: 456, processStartTime: 'next-start' }), -- ).rejects.toBeInstanceOf(FileLockBusyError); -- await expect(fs.readFile(lockPath, 'utf-8')).resolves.toBe(content); -- await fs.rm(lockPath); -- } -- -- await fs.mkdir(lockPath, { recursive: true }); -- await expect( -- acquireFileLock(lockPath, { pid: 456, processStartTime: 'next-start' }), -- ).rejects.toBeInstanceOf(FileLockBusyError); -- await expect(fs.access(lockPath)).resolves.toBeUndefined(); -- }); -- -- it('recovers when a stale reclaim guard was left by a crashed contender', async () => { -- const lockPath = await tempLockPath(); -- await acquireFileLock(lockPath, { pid: 999, processStartTime: 'abandoned' }); -- await acquireFileLock(`${lockPath}.reclaim`, { -- pid: 998, -- processStartTime: 'abandoned-reclaimer', -- }); -- -- const release = await acquireFileLock(lockPath, { -- pid: 1000, -- processStartTime: 'next', -- isProcessAlive: () => false, -- }); -- -- await release(); -- }); -- -- it('waits for the current holder when retries are configured', async () => { -- const lockPath = await tempLockPath(); -- const release = await acquireFileLock(lockPath); -- const next = acquireFileLock(lockPath, { retries: 20, retryDelayMs: 5 }); -- -- await sleep(10); -- await release(); -- const nextRelease = await next; -- -- await nextRelease(); -- }); -- -- it('fails closed while another stale-lock recovery is in progress', async () => { -- const lockPath = await tempLockPath(); -- const oldRelease = await acquireFileLock(lockPath, { -- pid: 999, -- processStartTime: 'abandoned', -- }); -- const reclaimGuardPath = `${lockPath}.reclaim`; -- await fs.mkdir(reclaimGuardPath); -- -- await expect( -- acquireFileLock(lockPath, { -- pid: 1000, -- processStartTime: 'next', -- isProcessAlive: () => false, -- }), -- ).rejects.toBeInstanceOf(FileLockBusyError); -- await expect(fs.access(lockPath)).resolves.toBeUndefined(); -- -- await fs.rmdir(reclaimGuardPath); -- const nextRelease = await acquireFileLock(lockPath, { -- pid: 1000, -- processStartTime: 'next', -- isProcessAlive: () => false, -- }); -- await oldRelease(); -- await nextRelease(); -- }); -- -- it('allows only one contender to recover an abandoned lock', async () => { -- const lockPath = await tempLockPath(); -- await acquireFileLock(lockPath, { pid: 999, processStartTime: 'abandoned' }); -- const starts = new Map( -- Array.from({ length: 8 }, (_, index) => [1000 + index, `start-${index}`]), -- ); -- -- const results = await Promise.allSettled( -- [...starts].map(([pid, processStartTime]) => -- acquireFileLock(lockPath, { -- pid, -- processStartTime, -- isProcessAlive: (ownerPid) => ownerPid !== 999, -- readProcessStartTime: (ownerPid) => starts.get(ownerPid), -- }), -- ), -- ); -- -- const acquired = results.filter( -- (result): result is PromiseFulfilledResult<() => Promise> => -- result.status === 'fulfilled', -- ); -- expect(acquired).toHaveLength(1); -- for (const result of results) { -- if (result.status === 'rejected') expect(result.reason).toBeInstanceOf(FileLockBusyError); -- } -- await acquired[0].value(); -- }); --}); -diff --git a/gitnexus/test/unit/git-clone.test.ts b/gitnexus/test/unit/git-clone.test.ts -index 0c365e359..4db09c837 100644 ---- a/gitnexus/test/unit/git-clone.test.ts -+++ b/gitnexus/test/unit/git-clone.test.ts -@@ -16,24 +16,20 @@ vi.mock('../../src/core/logger.js', () => ({ - - import { - extractRepoName, -- extractWebRepoName, - getCloneDir, - validateGitUrl, - cloneOrPull, - buildCloneArgs, -- buildBranchCloneArgs, - buildGitEnv, - normalizeGitUrlForCompare, - assertRemoteMatchesRequestedUrl, - isAzureDevOpsUrl, - warnIfInsecureAzureConfig, -- runGitForTest, - } from '../../src/server/git-clone.js'; - import path from 'node:path'; - import os from 'node:os'; - import fs from 'node:fs/promises'; - import { spawn } from 'node:child_process'; --import { EventEmitter } from 'node:events'; - import { getRemoteOriginUrl } from '../../src/storage/git.js'; - import { getGlobalDir } from '../../src/storage/repo-manager.js'; - -@@ -46,31 +42,6 @@ import { getGlobalDir } from '../../src/storage/repo-manager.js'; - // load, the same point CLONE_ROOT is frozen, so the two always agree. - const EXPECTED_CLONE_ROOT = path.resolve(path.join(getGlobalDir(), 'repos')); - --async function mkControlledRoot(prefix: string): Promise { -- const base = path.join(process.cwd(), '.tmp-test'); -- await fs.mkdir(base, { recursive: true }); -- return fs.realpath(await fs.mkdtemp(path.join(base, prefix))); --} -- --function runGit(args: string[], cwd: string): Promise { -- return new Promise((resolve, reject) => { -- const proc = spawn('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] }); -- let stdout = ''; -- let stderr = ''; -- proc.stdout.on('data', (chunk: Buffer) => { -- stdout += chunk; -- }); -- proc.stderr.on('data', (chunk: Buffer) => { -- stderr += chunk; -- }); -- proc.on('close', (code) => { -- if (code === 0) resolve(stdout); -- else reject(new Error(`git ${args.join(' ')} failed (${code}): ${stderr}`)); -- }); -- proc.on('error', reject); -- }); --} -- - describe('git-clone', () => { - describe('extractRepoName', () => { - it('extracts name from HTTPS URL', () => { -@@ -124,39 +95,29 @@ describe('git-clone', () => { - expect(elapsedMs).toBeLessThan(500); - }); - -- it('rejects leading dashes to prevent argument injection', () => { -- expect(() => extractRepoName('https://github.com/user/--upload-pack=payload.git')).toThrow( -- 'valid repository name', -- ); -- expect(() => extractRepoName('https://github.com/user/-repo')).toThrow( -- 'valid repository name', -+ it('strips leading dashes to prevent argument injection', () => { -+ expect(extractRepoName('https://github.com/user/--upload-pack=payload.git')).toBe( -+ 'upload-pack_payload', - ); -+ expect(extractRepoName('https://github.com/user/-repo')).toBe('repo'); - }); - -- it('rejects unsafe directory characters instead of sanitizing them', () => { -- expect(() => extractRepoName('https://github.com/user/repo.git')).toThrow( -- 'valid repository name', -- ); -+ it('sanitizes unsafe directory characters', () => { -+ // sanitizeRepoName turns into _tag_ -+ expect(extractRepoName('https://github.com/user/repo.git')).toBe('repo_tag_'); - }); - -- it('rejects shell metacharacters in URL segments', () => { -+ it('sanitizes shell metacharacters in URL segments', () => { - // The split on /[/:]/ does not split on backslashes or other shell chars, -- // so a name like `repo;rm -rf /` must fail instead of being rewritten. -- expect(() => extractRepoName('https://example.com/foo:repo;rm')).toThrow( -- 'valid repository name', -- ); -- expect(() => extractRepoName('https://example.com/foo:repo$x')).toThrow( -- 'valid repository name', -- ); -+ // so a name like `repo;rm -rf /` would slip through without the pattern. -+ // After fix/sanitize-repo-name, these are sanitized to underscores. -+ expect(extractRepoName('https://example.com/foo:repo;rm')).toBe('repo_rm'); -+ expect(extractRepoName('https://example.com/foo:repo$x')).toBe('repo_x'); - }); - -- it('rejects whitespace and backslashes', () => { -- expect(() => extractRepoName('https://example.com/foo:repo name')).toThrow( -- 'valid repository name', -- ); -- expect(() => extractRepoName('https://example.com/foo:repo\\name')).toThrow( -- 'valid repository name', -- ); -+ it('sanitizes whitespace and backslashes', () => { -+ expect(extractRepoName('https://example.com/foo:repo name')).toBe('repo_name'); -+ expect(extractRepoName('https://example.com/foo:repo\\name')).toBe('repo_name'); - }); - }); - -@@ -197,15 +158,6 @@ describe('git-clone', () => { - expect(() => validateGitUrl('http://gitlab.com/user/repo.git')).not.toThrow(); - }); - -- it('rejects query strings and fragments instead of reinterpreting clone remotes', () => { -- expect(() => validateGitUrl('https://github.com/user/repo.git?ref=main')).toThrow( -- 'must not include query strings or fragments', -- ); -- expect(() => validateGitUrl('https://github.com/user/repo.git#main')).toThrow( -- 'must not include query strings or fragments', -- ); -- }); -- - it('blocks SSH protocol', () => { - expect(() => validateGitUrl('ssh://git@github.com/user/repo.git')).toThrow( - 'Only https:// and http://', -@@ -397,23 +349,9 @@ describe('git-clone', () => { - expect(args.some((a) => a.toLowerCase().includes('authorization'))).toBe(false); - expect(args.some((a) => a.includes('extraHeader'))).toBe(false); - }); -- -- it('adds --branch before the URL separator for branch-specific clones', () => { -- const args = buildBranchCloneArgs('git@github.com:owner/repo.git', '/safe/target', 'develop'); -- expect(args).toEqual([ -- 'clone', -- '--depth', -- '1', -- '--branch', -- 'develop', -- '--', -- 'git@github.com:owner/repo.git', -- '/safe/target', -- ]); -- }); - }); - -- describe('buildGitEnv — managed git environment', () => { -+ describe('buildGitEnv — token injection', () => { - // The token MUST travel via GIT_CONFIG_* env vars (git ≥2.31), not via - // argv or URL. This keeps it out of `ps`, shell history, and stderr. - -@@ -437,33 +375,33 @@ describe('git-clone', () => { - expect(env.GIT_CURL_VERBOSE).toBeUndefined(); - }); - -- it('disables repository hooks even when no token is provided', () => { -+ it('does not set GIT_CONFIG_* env vars when no token is provided', () => { - const env = buildGitEnv({}); -- expect(env.GIT_CONFIG_COUNT).toBe('1'); -- expect(env.GIT_CONFIG_KEY_0).toBe('core.hooksPath'); -- expect(env.GIT_CONFIG_VALUE_0).toBe(os.devNull); -+ expect(env.GIT_CONFIG_COUNT).toBeUndefined(); -+ expect(env.GIT_CONFIG_KEY_0).toBeUndefined(); -+ expect(env.GIT_CONFIG_VALUE_0).toBeUndefined(); - }); - -- it('only disables repository hooks when token is empty string', () => { -+ it('also leaves GIT_CONFIG_* unset when token is empty string', () => { - const env = buildGitEnv({}, { token: '' }); -- expect(env.GIT_CONFIG_COUNT).toBe('1'); -- expect(env.GIT_CONFIG_KEY_0).toBe('core.hooksPath'); -- expect(env.GIT_CONFIG_VALUE_0).toBe(os.devNull); -+ expect(env.GIT_CONFIG_COUNT).toBeUndefined(); -+ expect(env.GIT_CONFIG_KEY_0).toBeUndefined(); -+ expect(env.GIT_CONFIG_VALUE_0).toBeUndefined(); - }); - - it('injects a host-scoped Basic-auth header when a github.com token is provided', () => { - const env = buildGitEnv({}, { token: 'ghp_secret123', url: 'https://github.com/owner/repo' }); -- expect(env.GIT_CONFIG_COUNT).toBe('2'); -+ expect(env.GIT_CONFIG_COUNT).toBe('1'); - // Host-scoped key: the header attaches only to this origin's requests. -- expect(env.GIT_CONFIG_KEY_1).toBe('http.https://github.com/owner/repo.extraHeader'); -+ expect(env.GIT_CONFIG_KEY_0).toBe('http.https://github.com/owner/repo.extraHeader'); - const expected = - 'Authorization: Basic ' + Buffer.from('x-access-token:ghp_secret123').toString('base64'); -- expect(env.GIT_CONFIG_VALUE_1).toBe(expected); -+ expect(env.GIT_CONFIG_VALUE_0).toBe(expected); - }); - - it('does not inject a token for a non-github host (defense-in-depth host bind)', () => { - const env = buildGitEnv({}, { token: 'ghp_secret123', url: 'https://gitlab.com/owner/repo' }); -- expect(env.GIT_CONFIG_COUNT).toBe('1'); -+ expect(env.GIT_CONFIG_COUNT).toBeUndefined(); - }); - - it('never includes the raw token value in any env entry', () => { -@@ -472,7 +410,7 @@ describe('git-clone', () => { - const token = 'ghp_uniqueRawSecret_98765'; - const env = buildGitEnv({ EXISTING: 'value' }, { token, url: 'https://github.com/o/r' }); - for (const [key, value] of Object.entries(env)) { -- if (key === 'GIT_CONFIG_VALUE_1') continue; -+ if (key === 'GIT_CONFIG_VALUE_0') continue; - expect(String(value)).not.toContain(token); - } - }); -@@ -482,12 +420,12 @@ describe('git-clone', () => { - process.env.AZURE_DEVOPS_PAT = 'azure-pat-xyz'; - try { - const env = buildGitEnv({}, { url: 'https://dev.azure.com/org/proj/_git/repo' }); -- expect(env.GIT_CONFIG_COUNT).toBe('2'); -- expect(env.GIT_CONFIG_KEY_1).toBe( -+ expect(env.GIT_CONFIG_COUNT).toBe('1'); -+ expect(env.GIT_CONFIG_KEY_0).toBe( - 'http.https://dev.azure.com/org/proj/_git/repo.extraHeader', - ); - const expected = 'Authorization: Basic ' + Buffer.from(':azure-pat-xyz').toString('base64'); -- expect(env.GIT_CONFIG_VALUE_1).toBe(expected); -+ expect(env.GIT_CONFIG_VALUE_0).toBe(expected); - } finally { - if (prev === undefined) delete process.env.AZURE_DEVOPS_PAT; - else process.env.AZURE_DEVOPS_PAT = prev; -@@ -501,8 +439,8 @@ describe('git-clone', () => { - process.env.AZURE_DEVOPS_PAT = 'azure-pat-xyz'; - try { - const env = buildGitEnv({}, { token: 'ghp_secret123', url: 'https://github.com/o/r' }); -- expect(env.GIT_CONFIG_COUNT).toBe('2'); -- expect(env.GIT_CONFIG_VALUE_2).toBeUndefined(); -+ expect(env.GIT_CONFIG_COUNT).toBe('1'); -+ expect(env.GIT_CONFIG_VALUE_1).toBeUndefined(); - for (const value of Object.values(env)) { - expect(String(value)).not.toContain('azure-pat-xyz'); - } -@@ -517,48 +455,13 @@ describe('git-clone', () => { - { GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'http.sslVerify', GIT_CONFIG_VALUE_0: 'true' }, - { token: 'ghp_secret123', url: 'https://github.com/o/r' }, - ); -- expect(env.GIT_CONFIG_COUNT).toBe('3'); -+ expect(env.GIT_CONFIG_COUNT).toBe('2'); - // Operator's pre-existing config is preserved at index 0. - expect(env.GIT_CONFIG_KEY_0).toBe('http.sslVerify'); - expect(env.GIT_CONFIG_VALUE_0).toBe('true'); -- expect(env.GIT_CONFIG_KEY_1).toBe('core.hooksPath'); -- expect(env.GIT_CONFIG_VALUE_1).toBe(os.devNull); -- expect(env.GIT_CONFIG_KEY_2).toBe('http.https://github.com/o/r.extraHeader'); -- expect(env.GIT_CONFIG_VALUE_2).toContain('Authorization: Basic '); -- }); -- -- it('overrides an inherited hooks path with the managed safe value', () => { -- const env = buildGitEnv({ -- GIT_CONFIG_COUNT: '1', -- GIT_CONFIG_KEY_0: 'core.hooksPath', -- GIT_CONFIG_VALUE_0: '/tmp/untrusted-hooks', -- }); -- expect(env.GIT_CONFIG_COUNT).toBe('2'); -- expect(env.GIT_CONFIG_KEY_1).toBe('core.hooksPath'); -- expect(env.GIT_CONFIG_VALUE_1).toBe(os.devNull); -- }); -- -- it('does not execute hooks from an existing repository', async () => { -- if (process.platform === 'win32') return; -- const root = await mkControlledRoot('gitnexus-managed-git-'); -- const marker = path.join(root, 'hook-ran'); -- try { -- await runGit(['init', '--initial-branch=main'], root); -- await runGit(['config', 'user.email', 'test@example.com'], root); -- await runGit(['config', 'user.name', 'GitNexus Test'], root); -- await fs.writeFile(path.join(root, 'README.md'), 'test\n'); -- await runGit(['add', 'README.md'], root); -- await runGit(['commit', '-m', 'initial'], root); -- const hook = path.join(root, '.git', 'hooks', 'post-checkout'); -- await fs.writeFile(hook, `#!/bin/sh\ntouch ${JSON.stringify(marker)}\n`); -- await fs.chmod(hook, 0o700); -- -- await runGitForTest(['checkout', '-b', 'next'], root); -- -- await expect(fs.access(marker)).rejects.toThrow(); -- } finally { -- await fs.rm(root, { recursive: true, force: true }); -- } -+ // Our credential is appended at index 1. -+ expect(env.GIT_CONFIG_KEY_1).toBe('http.https://github.com/o/r.extraHeader'); -+ expect(env.GIT_CONFIG_VALUE_1).toContain('Authorization: Basic '); - }); - - it('strips control characters from the config key (no key injection)', () => { -@@ -566,7 +469,7 @@ describe('git-clone', () => { - {}, - { token: 'ghp_secret123', url: 'https://github.com/o/r%0Anewline' }, - ); -- const key = env.GIT_CONFIG_KEY_1 ?? ''; -+ const key = env.GIT_CONFIG_KEY_0 ?? ''; - expect(key).not.toContain('\n'); - expect(key).not.toContain('\r'); - }); -@@ -631,387 +534,6 @@ describe('git-clone', () => { - 'Only https:// and http://', - ); - }); -- -- it('keeps regular cloneOrPull restricted to http and https URLs', async () => { -- const root = await mkControlledRoot('gitnexus-controlled-root-'); -- try { -- await expect( -- cloneOrPull('git@github.com:owner/repo.git', path.join(root, 'repo'), undefined, { -- allowedCloneRoot: root, -- expectedRepoName: 'repo', -- }), -- ).rejects.toThrow('Invalid URL'); -- } finally { -- await fs.rm(root, { recursive: true, force: true }); -- } -- }); -- -- it('creates missing nested parents before checking controlled clone containment', async () => { -- const root = await mkControlledRoot('gitnexus-controlled-root-'); -- const target = path.join(root, 'github.com', 'owner', 'repo'); -- const runGitForTest = vi.fn(async () => { -- await fs.mkdir(path.join(target, '.git'), { recursive: true }); -- return ''; -- }); -- try { -- await expect( -- cloneOrPull('git@github.com:owner/repo', target, undefined, { -- allowedCloneRoot: root, -- expectedRepoName: 'repo', -- allowAutoSyncSsh: true, -- runGitForTest, -- }), -- ).resolves.toBe(target); -- -- expect(runGitForTest).toHaveBeenCalledOnce(); -- } finally { -- await fs.rm(root, { recursive: true, force: true }); -- } -- }); -- -- it('allows auto-sync SSH SCP clone URLs with a per-repo timeout', async () => { -- const root = await mkControlledRoot('gitnexus-controlled-root-'); -- const target = path.join(root, 'repo'); -- const runGitForTest = vi.fn(async () => { -- await fs.mkdir(target); -- return ''; -- }); -- try { -- await expect( -- cloneOrPull('git@gitlab.com:group/subgroup/repo.git', target, undefined, { -- allowedCloneRoot: root, -- expectedRepoName: 'repo', -- allowAutoSyncSsh: true, -- timeoutMs: 10_000, -- branch: 'develop', -- runGitForTest, -- }), -- ).resolves.toBe(target); -- -- expect(runGitForTest).toHaveBeenCalledWith( -- [ -- 'clone', -- '--depth', -- '1', -- '--branch', -- 'develop', -- '--', -- 'git@gitlab.com:group/subgroup/repo.git', -- target, -- ], -- undefined, -- { token: undefined, url: 'git@gitlab.com:group/subgroup/repo.git', timeoutMs: 10_000 }, -- ); -- } finally { -- await fs.rm(root, { recursive: true, force: true }); -- } -- }); -- -- it('allows an explicitly controlled auto-sync clone root outside the default root', async () => { -- const root = await mkControlledRoot('gitnexus-controlled-root-'); -- try { -- const target = path.join(root, 'repo'); -- await expect( -- cloneOrPull('http://127.0.0.1/repo.git', target, undefined, { -- allowedCloneRoot: root, -- expectedRepoName: 'repo', -- }), -- ).rejects.toThrow('private/internal'); -- } finally { -- await fs.rm(root, { recursive: true, force: true }); -- } -- }); -- -- it('rejects controlled-root target names that do not match the remote repo name', async () => { -- const root = await mkControlledRoot('gitnexus-controlled-root-'); -- try { -- await expect( -- cloneOrPull('https://example.com/team/repo.git', path.join(root, 'other'), undefined, { -- allowedCloneRoot: root, -- expectedRepoName: 'repo', -- }), -- ).rejects.toThrow('basename must match'); -- } finally { -- await fs.rm(root, { recursive: true, force: true }); -- } -- }); -- -- it('rejects symlink children before clone or pull', async () => { -- const root = await mkControlledRoot('gitnexus-controlled-root-'); -- const outside = await mkControlledRoot('gitnexus-outside-'); -- try { -- await fs.symlink(outside, path.join(root, 'repo')); -- await expect( -- cloneOrPull('https://example.com/team/repo.git', path.join(root, 'repo'), undefined, { -- allowedCloneRoot: root, -- expectedRepoName: 'repo', -- }), -- ).rejects.toThrow('symlink'); -- } finally { -- await fs.rm(root, { recursive: true, force: true }); -- await fs.rm(outside, { recursive: true, force: true }); -- } -- }); -- -- it('rejects writable existing directories below a controlled clone root', async () => { -- if (process.platform === 'win32') return; -- const root = await mkControlledRoot('gitnexus-controlled-root-'); -- const namespace = path.join(root, 'team'); -- try { -- await fs.mkdir(namespace); -- await fs.chmod(namespace, 0o777); -- await expect( -- cloneOrPull( -- 'https://example.com/team/repo.git', -- path.join(namespace, 'repo'), -- undefined, -- { -- allowedCloneRoot: root, -- expectedRepoName: 'repo', -- }, -- ), -- ).rejects.toThrow('world-writable'); -- } finally { -- await fs.chmod(namespace, 0o700).catch(() => {}); -- await fs.rm(root, { recursive: true, force: true }); -- } -- }); -- -- it('rejects writable .git metadata in an existing controlled clone', async () => { -- if (process.platform === 'win32') return; -- const root = await mkControlledRoot('gitnexus-controlled-root-'); -- const target = path.join(root, 'repo'); -- const gitDir = path.join(target, '.git'); -- try { -- await fs.mkdir(gitDir, { recursive: true }); -- await fs.chmod(gitDir, 0o777); -- await expect( -- cloneOrPull('https://example.com/team/repo.git', target, undefined, { -- allowedCloneRoot: root, -- expectedRepoName: 'repo', -- }), -- ).rejects.toThrow('world-writable'); -- } finally { -- await fs.chmod(gitDir, 0o700).catch(() => {}); -- await fs.rm(root, { recursive: true, force: true }); -- } -- }); -- -- it('rejects symlinked .git metadata in an existing controlled clone', async () => { -- const root = await mkControlledRoot('gitnexus-controlled-root-'); -- const outside = await mkControlledRoot('gitnexus-outside-git-dir-'); -- const target = path.join(root, 'repo'); -- try { -- await fs.mkdir(target); -- await fs.symlink(outside, path.join(target, '.git')); -- await expect( -- cloneOrPull('https://example.com/team/repo.git', target, undefined, { -- allowedCloneRoot: root, -- expectedRepoName: 'repo', -- }), -- ).rejects.toThrow('symlink'); -- } finally { -- await fs.rm(root, { recursive: true, force: true }); -- await fs.rm(outside, { recursive: true, force: true }); -- } -- }); -- -- it('rejects existing clones whose remote origin mismatches the requested URL', async () => { -- const root = await mkControlledRoot('gitnexus-controlled-root-'); -- const target = path.join(root, 'repo'); -- try { -- await new Promise((resolve, reject) => { -- const proc = spawn('git', ['init'], { cwd: root, stdio: 'ignore' }); -- proc.on('close', (code) => -- code === 0 ? resolve() : reject(new Error(`git init ${code}`)), -- ); -- proc.on('error', reject); -- }); -- await fs.rename(path.join(root, '.git'), path.join(target, '.git')).catch(async () => { -- await fs.mkdir(target); -- await fs.rename(path.join(root, '.git'), path.join(target, '.git')); -- }); -- await fs.writeFile( -- path.join(target, '.git', 'config'), -- [ -- '[remote "origin"]', -- '\turl = https://example.com/other/repo.git', -- '\tfetch = +refs/heads/*:refs/remotes/origin/*', -- '', -- ].join('\n'), -- ); -- -- await expect( -- cloneOrPull('https://example.com/team/repo.git', target, undefined, { -- allowedCloneRoot: root, -- expectedRepoName: 'repo', -- }), -- ).rejects.toThrow('not the requested URL'); -- } finally { -- await fs.rm(root, { recursive: true, force: true }); -- } -- }); -- -- it('switches a shallow single-branch clone to a fallback branch', async () => { -- const root = await mkControlledRoot('gitnexus-shallow-fallback-'); -- const source = path.join(root, 'source'); -- const remote = path.join(root, 'remote.git'); -- const target = path.join(root, 'repo'); -- const remoteUrl = 'git@github.com:team/repo.git'; -- const gitConfig = path.join(root, 'gitconfig'); -- const previousGlobalConfig = process.env.GIT_CONFIG_GLOBAL; -- const previousNoSystemConfig = process.env.GIT_CONFIG_NOSYSTEM; -- -- try { -- await runGit(['init', '--bare', remote], root); -- await runGit(['init', '--initial-branch=master', source], root); -- await runGit(['config', 'user.email', 'test@example.com'], source); -- await runGit(['config', 'user.name', 'GitNexus Test'], source); -- await fs.writeFile(path.join(source, 'branch.txt'), 'master\n'); -- await runGit(['add', 'branch.txt'], source); -- await runGit(['commit', '-m', 'master'], source); -- await runGit(['checkout', '-b', 'main'], source); -- await fs.writeFile(path.join(source, 'branch.txt'), 'main\n'); -- await runGit(['commit', '-am', 'main'], source); -- await runGit(['remote', 'add', 'origin', `file://${remote}`], source); -- await runGit(['push', 'origin', 'master', 'main'], source); -- -- await fs.writeFile( -- gitConfig, -- `[protocol "file"]\n\tallow = always\n[url "file://${remote}"]\n\tinsteadOf = ${remoteUrl}\n`, -- ); -- process.env.GIT_CONFIG_GLOBAL = gitConfig; -- process.env.GIT_CONFIG_NOSYSTEM = '1'; -- -- await runGit(['clone', '--depth', '1', '--branch', 'master', remoteUrl, target], root); -- await expect( -- runGit(['show-ref', '--verify', '--quiet', 'refs/remotes/origin/main'], target), -- ).rejects.toThrow(); -- await expect(runGit(['rev-parse', '--is-shallow-repository'], target)).resolves.toBe( -- 'true\n', -- ); -- -- await fs.writeFile(path.join(target, 'branch.txt'), 'local changes\n'); -- await expect( -- cloneOrPull(remoteUrl, target, undefined, { -- allowedCloneRoot: root, -- expectedRepoName: 'repo', -- allowAutoSyncSsh: true, -- branch: 'main', -- }), -- ).rejects.toThrow(); -- await expect(fs.readFile(path.join(target, 'branch.txt'), 'utf8')).resolves.toBe( -- 'local changes\n', -- ); -- -- await cloneOrPull(remoteUrl, target, undefined, { -- allowedCloneRoot: root, -- expectedRepoName: 'repo', -- allowAutoSyncSsh: true, -- branch: 'main', -- overwriteLocalChanges: true, -- }); -- -- await expect(runGit(['branch', '--show-current'], target)).resolves.toBe('main\n'); -- await expect(fs.readFile(path.join(target, 'branch.txt'), 'utf8')).resolves.toBe('main\n'); -- await expect(runGit(['rev-parse', 'main'], target)).resolves.toBe( -- await runGit(['rev-parse', 'origin/main'], target), -- ); -- } finally { -- if (previousGlobalConfig === undefined) delete process.env.GIT_CONFIG_GLOBAL; -- else process.env.GIT_CONFIG_GLOBAL = previousGlobalConfig; -- if (previousNoSystemConfig === undefined) delete process.env.GIT_CONFIG_NOSYSTEM; -- else process.env.GIT_CONFIG_NOSYSTEM = previousNoSystemConfig; -- await fs.rm(root, { recursive: true, force: true }); -- } -- }); -- -- it('clones into a pre-existing empty target directory', async () => { -- const root = await mkControlledRoot('gitnexus-controlled-root-'); -- const target = path.join(root, 'repo'); -- const runGitForTest = vi.fn(async () => ''); -- try { -- await fs.mkdir(target); -- await expect( -- cloneOrPull('https://example.com/team/repo.git', target, undefined, { -- allowedCloneRoot: root, -- expectedRepoName: 'repo', -- runGitForTest, -- }), -- ).resolves.toBe(target); -- expect(runGitForTest).toHaveBeenCalled(); -- } finally { -- await fs.rm(root, { recursive: true, force: true }); -- } -- }); -- -- it('quarantines partial auto-sync clone output on clone failure', async () => { -- const root = await mkControlledRoot('gitnexus-controlled-root-'); -- const quarantineRoot = path.join(root, 'quarantine'); -- const target = path.join(root, 'repo'); -- try { -- await expect( -- cloneOrPull('https://example.com/team/repo.git', target, undefined, { -- allowedCloneRoot: root, -- expectedRepoName: 'repo', -- quarantineRoot, -- runGitForTest: async () => { -- await fs.mkdir(target); -- await fs.writeFile(path.join(target, 'partial.txt'), 'partial', 'utf-8'); -- throw new Error('git clone failed (exit code 128)'); -- }, -- }), -- ).rejects.toThrow('git clone failed'); -- -- const entries = await fs.readdir(quarantineRoot); -- expect( -- entries.some((entry) => entry.startsWith('auto-sync-') && entry.endsWith('-repo')), -- ).toBe(true); -- } finally { -- await fs.rm(root, { recursive: true, force: true }); -- } -- }); -- -- it('does not quarantine an existing non-git directory on clone failure', async () => { -- const root = await mkControlledRoot('gitnexus-controlled-root-'); -- const quarantineRoot = path.join(root, 'quarantine'); -- const target = path.join(root, 'repo'); -- try { -- await fs.mkdir(target); -- await fs.writeFile(path.join(target, 'user-file.txt'), 'keep me', 'utf-8'); -- -- await expect( -- cloneOrPull('https://example.com/team/repo.git', target, undefined, { -- allowedCloneRoot: root, -- expectedRepoName: 'repo', -- quarantineRoot, -- }), -- ).rejects.toThrow('already exists but is not a git repository'); -- -- await expect(fs.readFile(path.join(target, 'user-file.txt'), 'utf-8')).resolves.toBe( -- 'keep me', -- ); -- await expect(fs.access(quarantineRoot)).rejects.toThrow(); -- } finally { -- await fs.rm(root, { recursive: true, force: true }); -- } -- }); -- -- it('rejects controlled clone roots with unsafe permissions inside cloneOrPull', async () => { -- const root = await mkControlledRoot('gitnexus-controlled-root-'); -- try { -- await fs.chmod(root, 0o777); -- await expect( -- cloneOrPull('https://example.com/team/repo.git', path.join(root, 'repo'), undefined, { -- allowedCloneRoot: root, -- expectedRepoName: 'repo', -- }), -- ).rejects.toThrow('world-writable'); -- } finally { -- await fs.chmod(root, 0o700).catch(() => {}); -- await fs.rm(root, { recursive: true, force: true }); -- } -- }); - }); - - describe('isAzureDevOpsUrl', () => { -@@ -1124,30 +646,6 @@ describe('git-clone', () => { - }); - }); - -- describe('extractWebRepoName — API clone compatibility', () => { -- it('sanitizes repo names with spaces and unsafe directory characters at the web boundary', () => { -- expect(extractWebRepoName('https://dev.azure.com/org/project/_git/My Repo With Spaces')).toBe( -- 'My_Repo_With_Spaces', -- ); -- expect(extractWebRepoName('https://example.com/team/repo$name.git')).toBe('repo_name'); -- }); -- -- it('keeps Windows reserved names from becoming clone directories', () => { -- expect(() => extractWebRepoName('https://example.com/team/CON.git')).toThrow( -- 'valid repository name', -- ); -- expect(() => extractWebRepoName('https://example.com/team/NUL.txt')).toThrow( -- 'valid repository name', -- ); -- }); -- -- it('leaves strict extractRepoName behavior unchanged for internal callers', () => { -- expect(() => extractRepoName('https://example.com/team/repo$name.git')).toThrow( -- 'valid repository name', -- ); -- }); -- }); -- - describe('validateGitUrl — Azure DevOps URLs', () => { - it('allows self-hosted Azure DevOps Server URLs', () => { - expect(() => -@@ -1326,42 +824,4 @@ describe('git-clone', () => { - } - }); - }); -- -- describe('runGit timeout', () => { -- it('rejects after SIGKILL even when the child never closes', async () => { -- vi.useFakeTimers(); -- try { -- const child = new EventEmitter() as EventEmitter & { -- stderr: EventEmitter; -- kill: ReturnType; -- }; -- child.stderr = new EventEmitter(); -- child.kill = vi.fn(); -- const spawnForTest = vi.fn(() => child) as unknown as typeof spawn; -- -- const promise = runGitForTest(['clone'], undefined, { -- timeoutMs: 20, -- timeoutKillGraceMs: 20, -- spawnForTest, -- }); -- -- await vi.advanceTimersByTimeAsync(25); -- let settled = false; -- promise -- .catch(() => {}) -- .finally(() => { -- settled = true; -- }); -- await vi.runAllTicks(); -- expect(child.kill).toHaveBeenCalledWith('SIGTERM'); -- expect(settled).toBe(false); -- -- await vi.advanceTimersByTimeAsync(25); -- expect(child.kill).toHaveBeenCalledWith('SIGKILL'); -- await expect(promise).rejects.toThrow('timed out after 20ms'); -- } finally { -- vi.useRealTimers(); -- } -- }); -- }); - }); -diff --git a/gitnexus/test/unit/hooks.test.ts b/gitnexus/test/unit/hooks.test.ts -index 54e709233..062c5b870 100644 ---- a/gitnexus/test/unit/hooks.test.ts -+++ b/gitnexus/test/unit/hooks.test.ts -@@ -413,32 +413,14 @@ describe('windowsHide regression', () => { - /** - * Count spawn-family invocations. The regex matches ``spawn(``, - * ``spawnSync(``, ``execFile(``, ``execFileSync(``, -- * ``execFileAsync(``, ``execSync(`` and simple local aliases that -- * point at one of those functions as function calls — not destructures -- * (``const { spawn } = ...``), not method calls (``.exec(``), not bare -- * ``exec()`` (which collides with regex ``.exec()``; we explicitly -- * drop it). -+ * ``execFileAsync(``, ``execSync(`` as function calls — not -+ * destructures (``const { spawn } = ...``), not method calls -+ * (``.exec(``), not bare ``exec()`` (which collides with regex -+ * ``.exec()``; we explicitly drop it). - */ - function countSpawnCalls(codeSource: string): number { -- const spawnFunctions = [ -- 'spawn', -- 'spawnSync', -- 'execFile', -- 'execFileSync', -- 'execFileAsync', -- 'execSync', -- ]; -- const spawnNames = new Set(spawnFunctions); -- const aliasRe = new RegExp( -- `\\bconst\\s+([A-Za-z_$][\\w$]*)\\s*=\\s*[^;\\n]*\\b(?:${spawnFunctions.join('|')})\\b`, -- 'g', -- ); -- let aliasMatch: RegExpExecArray | null; -- while ((aliasMatch = aliasRe.exec(codeSource)) !== null) { -- spawnNames.add(aliasMatch[1]); -- } -- -- const re = new RegExp(`(^|[^a-zA-Z0-9_$.])(${[...spawnNames].join('|')})\\s*\\(`, 'gm'); -+ const re = -+ /(^|[^a-zA-Z0-9_$.])(spawn|spawnSync|execFile|execFileSync|execFileAsync|execSync)\s*\(/gm; - let count = 0; - while (re.exec(codeSource) !== null) { - count++; -diff --git a/gitnexus/test/unit/process-identity.test.ts b/gitnexus/test/unit/process-identity.test.ts -deleted file mode 100644 -index 7b31f1d32..000000000 ---- a/gitnexus/test/unit/process-identity.test.ts -+++ /dev/null -@@ -1,42 +0,0 @@ --import { afterEach, describe, expect, it, vi } from 'vitest'; -- --import { isProcessAlive, readProcessStartTime } from '../../src/utils/process-identity.js'; -- --afterEach(() => { -- vi.restoreAllMocks(); --}); -- --describe('process identity', () => { -- it('treats only ESRCH as a dead process', () => { -- const kill = vi.spyOn(process, 'kill'); -- kill.mockImplementationOnce(() => { -- throw Object.assign(new Error('missing'), { code: 'ESRCH' }); -- }); -- kill.mockImplementationOnce(() => { -- throw Object.assign(new Error('not permitted'), { code: 'EPERM' }); -- }); -- -- expect(isProcessAlive(111)).toBe(false); -- expect(isProcessAlive(222)).toBe(true); -- }); -- -- it.skipIf(process.platform === 'win32')( -- 'renders the same start time regardless of the ambient timezone', -- () => { -- const original = process.env.TZ; -- try { -- process.env.TZ = 'UTC'; -- const utc = readProcessStartTime(process.pid); -- process.env.TZ = 'Asia/Tokyo'; -- const tokyo = readProcessStartTime(process.pid); -- -- expect(utc).toBeTruthy(); -- // A locale/timezone-dependent identity makes a live lock look reused. -- expect(tokyo).toBe(utc); -- } finally { -- if (original === undefined) delete process.env.TZ; -- else process.env.TZ = original; -- } -- }, -- ); --}); -diff --git a/gitnexus/test/unit/repo-manager-registry-atomic-write.test.ts b/gitnexus/test/unit/repo-manager-registry-atomic-write.test.ts -index 3c6c1c77f..ba0fbe777 100644 ---- a/gitnexus/test/unit/repo-manager-registry-atomic-write.test.ts -+++ b/gitnexus/test/unit/repo-manager-registry-atomic-write.test.ts -@@ -173,10 +173,8 @@ describe('writeRegistry — private tmp path per transaction (#2888)', () => { - it('keeps serving a validating read when the prune write fails', async () => { - await registerRepo(tmpRepoA.dbPath, meta, { name: 'gone' }); - fsCtx.renameMock.mockClear(); -- // EBUSY is normally retryable, but prune persistence is best-effort and -- // must not hold the registry lock through retry backoff. - fsCtx.renameMock.mockImplementationOnce(() => -- Promise.reject(Object.assign(new Error('mock busy registry'), { code: 'EBUSY' })), -+ Promise.reject(Object.assign(new Error('mock read-only home'), { code: 'EROFS' })), - ); - - const cap = _captureLogger(); -diff --git a/gitnexus/test/unit/repo-manager.test.ts b/gitnexus/test/unit/repo-manager.test.ts -index 34a91206f..468096a61 100644 ---- a/gitnexus/test/unit/repo-manager.test.ts -+++ b/gitnexus/test/unit/repo-manager.test.ts -@@ -841,25 +841,6 @@ describe('registerRepo name override + collision guard (#829)', () => { - expect(entries[0].name).not.toBe(path.basename(tmpRepoA.dbPath)); - }); - -- it('preserves every concurrent registration', async () => { -- const repoPaths = Array.from({ length: 12 }, (_, index) => -- path.join(tmpRepoA.dbPath, `concurrent-${index}`), -- ); -- await Promise.all(repoPaths.map((repoPath) => fs.mkdir(repoPath, { recursive: true }))); -- -- await Promise.all( -- repoPaths.map((repoPath, index) => -- registerRepo(repoPath, meta, { name: `concurrent-${index}` }), -- ), -- ); -- -- const entries = await listRegisteredRepos(); -- expect(entries).toHaveLength(repoPaths.length); -- expect(entries.map((entry) => entry.name).sort()).toEqual( -- repoPaths.map((_, index) => `concurrent-${index}`).sort(), -- ); -- }); -- - it('re-registerRepo on same path without name preserves an existing alias', async () => { - await registerRepo(tmpRepoA.dbPath, meta, { name: 'custom-alias' }); - // Second call with no opts should keep the alias, not revert to basename. -diff --git a/gitnexus/test/unit/watch-command.test.ts b/gitnexus/test/unit/watch-command.test.ts -deleted file mode 100644 -index 90eeda128..000000000 ---- a/gitnexus/test/unit/watch-command.test.ts -+++ /dev/null -@@ -1,43 +0,0 @@ --import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -- --const autoSync = vi.hoisted(() => ({ -- startAutoSyncWatch: vi.fn(), --})); -- --vi.mock('../../src/core/auto-sync/index.js', () => ({ -- getAutoSyncConfigPath: vi.fn(() => '/tmp/watch_config.yml'), -- getAutoSyncMutexPath: vi.fn(() => '/tmp/watch.mutex'), -- readAutoSyncWatchStatus: vi.fn(), -- resetAutoSyncState: vi.fn(), -- startAutoSyncWatch: autoSync.startAutoSyncWatch, -- stopAutoSyncWatch: vi.fn(), --})); -- --import { autoSyncCommand } from '../../src/cli/auto-sync.js'; -- --describe('auto-sync command', () => { -- beforeEach(() => vi.clearAllMocks()); -- afterEach(() => vi.restoreAllMocks()); -- -- it('reports foreground stop failures and exits non-zero', async () => { -- const stop = vi.fn(async () => { -- throw new Error('cleanup failed'); -- }); -- autoSync.startAutoSyncWatch.mockResolvedValue({ stop }); -- let signalHandler: (() => void) | undefined; -- vi.spyOn(process, 'once').mockImplementation(((event, listener) => { -- if (event === 'SIGTERM') signalHandler = listener as () => void; -- return process; -- }) as typeof process.once); -- const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); -- const exit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); -- -- await autoSyncCommand('start'); -- signalHandler?.(); -- await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1)); -- -- expect(stop).toHaveBeenCalledTimes(1); -- expect(stderr).toHaveBeenCalledWith('[auto-sync] Failed to stop watch: cleanup failed\n'); -- expect(stderr).not.toHaveBeenCalledWith('[auto-sync] Watch stopped.\n'); -- }); --}); -diff --git a/gitnexus/test/unit/watch-failure-policy.test.ts b/gitnexus/test/unit/watch-failure-policy.test.ts -index 96fcfff7c..aa32f0454 100644 ---- a/gitnexus/test/unit/watch-failure-policy.test.ts -+++ b/gitnexus/test/unit/watch-failure-policy.test.ts -@@ -7,7 +7,7 @@ vi.mock('../../src/core/run-analyze.js', () => ({ - runFullAnalysis: vi.fn(), - })); - --import { shouldStopAfterWatchRefreshFailure } from '../../src/cli/analyze-watch.js'; -+import { shouldStopAfterWatchRefreshFailure } from '../../src/cli/watch.js'; - - describe('watch refresh failure policy', () => { - beforeEach(() => analyzeFailureMayHaveMutatedLiveIndex.mockReset()); -diff --git a/gitnexus/test/unit/watch-paths.test.ts b/gitnexus/test/unit/watch-paths.test.ts -index 22fb9872b..397b29de8 100644 ---- a/gitnexus/test/unit/watch-paths.test.ts -+++ b/gitnexus/test/unit/watch-paths.test.ts -@@ -3,7 +3,7 @@ import fs from 'node:fs/promises'; - import os from 'node:os'; - import path from 'node:path'; - import { createWatchIgnorePredicate } from '../../src/config/ignore-service.js'; --import { isRelevantWatchPath, resolveWatchOptions } from '../../src/cli/analyze-watch.js'; -+import { isRelevantWatchPath, resolveWatchOptions } from '../../src/cli/watch.js'; - import * as git from '../../src/storage/git.js'; - - vi.mock('../../src/storage/git.js', () => ({ diff --git a/eval/workflow_bench/review_cases/pr-3124-clean.patch b/eval/workflow_bench/review_cases/pr-3124-clean.patch deleted file mode 100644 index 0f0fa4fbd..000000000 --- a/eval/workflow_bench/review_cases/pr-3124-clean.patch +++ /dev/null @@ -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 # 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 { -+ 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 { -+ 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// (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 { - 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 { - ); - } - } -- 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 { - 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// (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-*'); diff --git a/eval/workflow_bench/review_cases/pr-3124-defect.patch b/eval/workflow_bench/review_cases/pr-3124-defect.patch deleted file mode 100644 index 2a581ab82..000000000 --- a/eval/workflow_bench/review_cases/pr-3124-defect.patch +++ /dev/null @@ -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 # 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 { -+ 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 { -+ 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-*'); diff --git a/eval/workflow_bench/review_cases/pr-3153-clean.patch b/eval/workflow_bench/review_cases/pr-3153-clean.patch deleted file mode 100644 index 6c1f551e0..000000000 --- a/eval/workflow_bench/review_cases/pr-3153-clean.patch +++ /dev/null @@ -1,1038 +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 - All analyze flags - - ```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 ': '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 ', '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>(); - 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..8b70ef10e 100644 ---- a/gitnexus/src/core/run-analyze.ts -+++ b/gitnexus/src/core/run-analyze.ts -@@ -157,11 +157,11 @@ import { - saveParseCache, - pruneCache, - PARSE_CACHE_VERSION, -+ createColdParseRebuildDir, -+ emptyParseCache, -+ forgetCreatedParseCacheDir, - } from '../storage/parse-cache.js'; --import { -- getDurableParsedFileDir, -- pruneAndSaveDurableParsedFileStore, --} from '../storage/parsedfile-store.js'; -+import { mergeStagedDurableParsedFileStore } from '../storage/parsedfile-store.js'; - import { - getCurrentCommit, - getCurrentBranch, -@@ -332,12 +332,19 @@ export interface AnalyzeCallbacks { - - export interface AnalyzeOptions { - /** -- * Force a full re-index of the pipeline. Callers may OR this with -- * other flags that imply re-analysis (e.g. `--skills`), so the value -- * here is the PIPELINE-force signal, NOT the registry-collision -- * bypass. See `allowDuplicateName` below. -+ * Rebuild the graph and FTS. Parser output is still reused from the -+ * content-addressed parse cache unless `useParseCache` is false. -+ * Callers may OR this with other flags that imply re-analysis -+ * (e.g. `--skills`), so the value here is the PIPELINE-force signal, -+ * NOT the registry-collision bypass. See `allowDuplicateName` below. - */ - force?: boolean; -+ /** -+ * Reuse content-addressed parser output. Defaults to true. When false, -+ * analysis reparses every file and publishes a new parse-cache generation -+ * only after a successful run (live shards stay untouched if the run fails). -+ */ -+ useParseCache?: boolean; - /** Repair only search indexes without re-running full parsing/indexing. */ - repairFts?: boolean; - /** Emit per-index FTS create logs. */ -@@ -1029,6 +1036,18 @@ async function resolveWriteTarget(repoPath: string, options: AnalyzeOptions): Pr - }; - } - -+async function removeColdParseRebuildDir( -+ dir: string | undefined, -+ ignoreErrors: boolean, -+): Promise { -+ if (!dir) return; -+ try { -+ await fs.rm(dir, { recursive: true, force: true }); -+ } catch (err) { -+ if (!ignoreErrors) throw err; -+ } -+} -+ - /** - * Run the full analysis under an exclusive, index-directory-scoped write lock - * (#2658). A second concurrent `analyze` on the same slot waits here for the -@@ -1132,6 +1151,7 @@ async function runFullAnalysisInner( - // does not own the flat slot. See resolveWriteTarget for the full contract. - const { storagePath, repoHasGit, currentCommit, branchLabel, placement, lbugPath, metaDir } = - writeTarget; -+ let coldParseRebuildDir: string | undefined; - - // Start each analyze with a clean buffer-pool hint: any pre-pipeline DB open - // (e.g. the embeddings-cache open) falls back to the default until the hint is -@@ -1742,6 +1762,13 @@ async function runFullAnalysisInner( - options = { ...options, force: true }; - } - -+ // Programmatic `useParseCache: false` must set force or the up-to-date -+ // guard returns before the empty-cache construction below. -+ if (options.useParseCache === false && !options.force) { -+ log('Parser cache bypass requested; forcing a full rebuild so unchanged files are re-parsed.'); -+ options = { ...options, force: true }; -+ } -+ - // ── Early-return: already up to date ────────────────────────────── - if ( - existingMeta && -@@ -1964,11 +1991,18 @@ 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). -- // 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); -+ // Content-addressed: `--force` reuses parser shards; `useParseCache: false` -+ // stages a new generation under a run-unique parse-rebuild.* dir and publishes -+ // after success. Unique because index locks are per branch slot while this -+ // cache root is shared across branches. -+ if (options.useParseCache === false) { -+ coldParseRebuildDir = await createColdParseRebuildDir(storagePath); -+ forgetCreatedParseCacheDir(coldParseRebuildDir); -+ } -+ const parseCache = -+ options.useParseCache === false -+ ? emptyParseCache(coldParseRebuildDir) -+ : 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 -@@ -2006,53 +2040,69 @@ async function runFullAnalysisInner( - !schemaFingerprintMismatch(existingMeta.schemaFingerprint); - - // ── Phase 1: Full Pipeline (0–60%) ──────────────────────────────── -- const pipelineResult = await runPipelineFromRepo( -- repoPath, -- (p) => { -- const phaseLabel = PHASE_LABELS[p.phase] || p.phase; -- const scaled = Math.round(p.percent * 0.6); -- const message = p.detail -- ? `${p.message || phaseLabel} (${p.detail})` -- : p.message || phaseLabel; -- progress(p.phase, scaled, message); -- }, -- { -- parseCache, -- workerPoolSize: options.workerPoolSize, -- // CFG/PDG opt-in (#2081 M1). PipelineOptions.pdg fans out to the worker -- // build gate (workerData.pdg) and the scope-resolution emit gate. -- pdg: options.pdg === true, -- pdgMaxFunctionLines: options.pdgMaxFunctionLines, -- pdgMaxEdgesPerFunction: options.pdgMaxEdgesPerFunction, -- pdgMaxReachingDefEdgesPerFunction: options.pdgMaxReachingDefEdgesPerFunction, -- pdgMaxCdgEdgesPerFunction: options.pdgMaxCdgEdgesPerFunction, -- pdgMaxTaintFindingsPerFunction: options.pdgMaxTaintFindingsPerFunction, -- pdgMaxTaintHops: options.pdgMaxTaintHops, -- pdgMaxInterprocFindings: options.pdgMaxInterprocFindings, -- pdgMaxInterprocHops: options.pdgMaxInterprocHops, -- pdgMaxInterprocEdges: options.pdgMaxInterprocEdges, -- // Streaming/chunked PDG emit (#2202) — gated to full-rebuild runs -- // (force === true) so the incremental writeback never reads back an -- // offloaded BasicBlock layer. Memory-only; byte-identical output. -- streamPdgEmit: resolveStreamPdgEmit(options), -- pdgEmitChunkSize: resolvePdgEmitChunkSize(options), -- // Streamed structural emit (#2680) — same full-rebuild gate as the PDG -- // toggle above, for the same incremental-writeback reason. -- streamGraphEmit: streamGraphEmitActive, -- // Resolved ONLY when streaming is active: on a Windows non-ASCII storage -- // path this helper mkdtempSyncs a real directory, so evaluating it -- // unconditionally would leak one temp dir per analyze even with the flag -- // off. The PDG sibling resolves inside its guard for the same reason. -- graphEmitCsvDir: streamGraphEmitActive -- ? resolveNativeSafeStorageDir(storagePath, 'graph-csv') -- : undefined, -- fetchWrappers: options.fetchWrappers, -- skipDerivedGraphPhases, -- springActuatorPath: options.springActuatorPath, -- asyncApiSpecPath: options.asyncApiSpecPath, -- springActuatorScanExclusions, -- }, -- ); -+ let pipelineResult; -+ try { -+ pipelineResult = await runPipelineFromRepo( -+ repoPath, -+ (p) => { -+ const phaseLabel = PHASE_LABELS[p.phase] || p.phase; -+ const scaled = Math.round(p.percent * 0.6); -+ const message = p.detail -+ ? `${p.message || phaseLabel} (${p.detail})` -+ : p.message || phaseLabel; -+ progress(p.phase, scaled, message); -+ }, -+ { -+ parseCache, -+ workerPoolSize: options.workerPoolSize, -+ // CFG/PDG opt-in (#2081 M1). PipelineOptions.pdg fans out to the worker -+ // build gate (workerData.pdg) and the scope-resolution emit gate. -+ pdg: options.pdg === true, -+ pdgMaxFunctionLines: options.pdgMaxFunctionLines, -+ pdgMaxEdgesPerFunction: options.pdgMaxEdgesPerFunction, -+ pdgMaxReachingDefEdgesPerFunction: options.pdgMaxReachingDefEdgesPerFunction, -+ pdgMaxCdgEdgesPerFunction: options.pdgMaxCdgEdgesPerFunction, -+ pdgMaxTaintFindingsPerFunction: options.pdgMaxTaintFindingsPerFunction, -+ pdgMaxTaintHops: options.pdgMaxTaintHops, -+ pdgMaxInterprocFindings: options.pdgMaxInterprocFindings, -+ pdgMaxInterprocHops: options.pdgMaxInterprocHops, -+ pdgMaxInterprocEdges: options.pdgMaxInterprocEdges, -+ // Streaming/chunked PDG emit (#2202) — gated to full-rebuild runs -+ // (force === true) so the incremental writeback never reads back an -+ // offloaded BasicBlock layer. Memory-only; byte-identical output. -+ streamPdgEmit: resolveStreamPdgEmit(options), -+ pdgEmitChunkSize: resolvePdgEmitChunkSize(options), -+ // Streamed structural emit (#2680) — same full-rebuild gate as the PDG -+ // toggle above, for the same incremental-writeback reason. -+ streamGraphEmit: streamGraphEmitActive, -+ // Resolved ONLY when streaming is active: on a Windows non-ASCII storage -+ // path this helper mkdtempSyncs a real directory, so evaluating it -+ // unconditionally would leak one temp dir per analyze even with the flag -+ // off. The PDG sibling resolves inside its guard for the same reason. -+ graphEmitCsvDir: streamGraphEmitActive -+ ? resolveNativeSafeStorageDir(storagePath, 'graph-csv') -+ : undefined, -+ fetchWrappers: options.fetchWrappers, -+ skipDerivedGraphPhases, -+ springActuatorPath: options.springActuatorPath, -+ asyncApiSpecPath: options.asyncApiSpecPath, -+ springActuatorScanExclusions, -+ }, -+ ); -+ } catch (err) { -+ await removeColdParseRebuildDir(coldParseRebuildDir, true); -+ throw err; -+ } -+ -+ if (options.force && (pipelineResult.parseCacheHitFileCount ?? 0) > 0) { -+ log( -+ `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...'); -@@ -3994,51 +4044,8 @@ async function runFullAnalysisInner( - // meta.indexedAt = T_new while lbugPath still resolves to the pre-swap - // inode (which latched the reader on the stale index permanently). The meta - // object is fully computed at this point; only its write is deferred. -- -- // Persist the incremental parse cache for the next run. Wraps in -- // try/catch so a cache-write failure never breaks an otherwise -- // successful indexing run. Prune stale chunk-hash entries first so -- // the cache file size stays bounded across runs (chunks whose -- // composition no longer matches anything in the current scan are -- // dead weight; the parse phase populates `usedKeys` as it processes -- // chunks). -- try { -- // #2106 R6: the parse cache + durable store are shared across branches. -- // Before pruning to this run's keys, fold in the OTHER branches' recorded -- // chunk keys so a branch switch doesn't evict their still-live shards. -- // Adding to usedKeys makes them survive pruneCache AND land in the saved -- // index (saveParseCache builds the index from usedKeys). Excludes this -- // run's own meta dir, so a single-branch repo folds in nothing → prune -- // set byte-identical to today. -- const { keys: siblingKeys, complete } = await collectBranchCacheKeys(storagePath, metaDir); -- if (complete) { -- for (const k of siblingKeys) parseCache.usedKeys.add(k); -- } else { -- // Fail-safe toward retention: a sibling meta was unreadable, so keep -- // everything currently loaded rather than evict on incomplete info. -- log('Parse cache: a branch meta was unreadable — retaining all cached chunks (#2106).'); -- for (const k of parseCache.entries.keys()) parseCache.usedKeys.add(k); -- } -- const pruned = pruneCache(parseCache, parseCache.usedKeys); -- if (pruned > 0) { -- log(`Parse cache: pruned ${pruned} stale chunk entries`); -- } -- const savedKeys = await saveParseCache(storagePath, parseCache); -- // Prune the durable ParsedFile store to EXACTLY the parse cache's -- // surviving keys (#2038 warm-cache coverage), so the two content-addressed -- // stores stay coherent: a chunk is "cached" iff both its parse-cache shard -- // and its durable shards exist. A quarantined chunk (in usedKeys but with -- // no parse-cache shard) drops its durable subdir here and re-dispatches -- // next run. Same try/catch — a durable-store write failure must never -- // break an otherwise successful run (next run treats it as a miss). -- await pruneAndSaveDurableParsedFileStore( -- getDurableParsedFileDir(storagePath), -- PARSE_CACHE_VERSION, -- new Set(savedKeys), -- ); -- } catch (e) { -- log(`Warning: could not save parse cache (${(e as Error).message}); continuing.`); -- } -+ // Parse-cache publish waits until after that swap + saveMeta so a failed -+ // registerRepo / close / swap cannot replace live shards (#3153). - - // Forward the --name alias and the registry-collision bypass bit. - // `allowDuplicateName` is its own concern — independent from the -@@ -4061,8 +4068,8 @@ async function runFullAnalysisInner( - // ── #2354: the flat workspace slot has adopted this run's branch ────── - // Drop a now-shadowed `branches//` sub-index for the same label - // (unreachable once the flat slot serves it) and align the registry's -- // top-level branch label. Best-effort like the parse-cache save above -- // (#2364 review F5): the index is complete and registered, and a failure -+ // top-level branch label. Best-effort (#2364 review F5): the index is -+ // complete and registered, and a failure - // here leaves only a stale registry label / undeleted shadowed dir — - // never wrong routing, because the flat meta this run already stamped is - // what applyBranchScope trusts. Retried by the next content-changing run -@@ -4190,8 +4197,55 @@ async function runFullAnalysisInner( - // live and the next run recovers via the full-rebuild path. - await saveMeta(metaDir, meta); - -+ // Persist the incremental parse cache only after a successful graph -+ // publish (#3153). try/catch so a cache-write failure never breaks an -+ // otherwise successful indexing run. Prune stale chunk-hash entries first -+ // so the cache file size stays bounded across runs (chunks whose -+ // composition no longer matches anything in the current scan are dead -+ // weight; the parse phase populates `usedKeys` as it processes chunks). -+ try { -+ // #2106 R6: the parse cache + durable store are shared across branches. -+ // Before pruning to this run's keys, fold in the OTHER branches' recorded -+ // chunk keys so a branch switch doesn't evict their still-live shards. -+ // Adding to usedKeys makes them survive pruneCache AND land in the saved -+ // index (saveParseCache builds the index from usedKeys). Excludes this -+ // run's own meta dir, so a single-branch repo folds in nothing → prune -+ // set byte-identical to today. -+ const { keys: siblingKeys, complete } = await collectBranchCacheKeys(storagePath, metaDir); -+ if (complete) { -+ for (const k of siblingKeys) parseCache.usedKeys.add(k); -+ } else { -+ // Fail-safe toward retention: a sibling meta was unreadable, so keep -+ // everything currently loaded rather than evict on incomplete info. -+ log('Parse cache: a branch meta was unreadable — retaining all cached chunks (#2106).'); -+ for (const k of parseCache.entries.keys()) parseCache.usedKeys.add(k); -+ } -+ const pruned = pruneCache(parseCache, parseCache.usedKeys); -+ if (pruned > 0) { -+ log(`Parse cache: pruned ${pruned} stale chunk entries`); -+ } -+ const savedKeys = await saveParseCache(storagePath, parseCache); -+ // Prune the durable ParsedFile store to EXACTLY the parse cache's -+ // surviving keys (#2038 warm-cache coverage), so the two content-addressed -+ // stores stay coherent: a chunk is "cached" iff both its parse-cache shard -+ // and its durable shards exist. A quarantined chunk (in usedKeys but with -+ // no parse-cache shard) drops its durable subdir here and re-dispatches -+ // next run. Same try/catch — a durable-store write failure must never -+ // break an otherwise successful run (next run treats it as a miss). -+ await mergeStagedDurableParsedFileStore( -+ storagePath, -+ parseCache.storagePath ?? storagePath, -+ PARSE_CACHE_VERSION, -+ new Set(savedKeys), -+ ); -+ } catch (e) { -+ log(`Warning: could not save parse cache (${(e as Error).message}); continuing.`); -+ } -+ - progress('done', 100, 'Done'); - -+ await removeColdParseRebuildDir(coldParseRebuildDir, true); -+ - return { - repoName: projectName, - repoPath, -@@ -4245,6 +4299,7 @@ async function runFullAnalysisInner( - /* swallow — orphan reclamation must never mask the real failure */ - } - } -+ await removeColdParseRebuildDir(coldParseRebuildDir, true); - if (liveIndexMutationStarted) { - // Preserve the original error identity/prototype: callers distinguish - // IndexLockTimeoutError and other domain failures with `instanceof`. -diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts -index 7b5cdc25c..743b810b4 100644 ---- a/gitnexus/src/storage/parse-cache.ts -+++ b/gitnexus/src/storage/parse-cache.ts -@@ -19,9 +19,9 @@ - * - Chunk-level invalidation gives a useful speedup floor (98% on a single - * 1-of-50 invalidated chunk) without touching the worker. - * -- * Survives `--force` because it's content-addressed: the same bytes always -- * produce the same key. `--force` only matters for the LadybugDB writeback; -- * the cache itself is always safe to reuse. -+ * `--force` still reuses content-addressed shards (it only rebuilds graph/FTS). -+ * `useParseCache: false` reparses every file, writes a staging generation, and -+ * publishes onto this cache only after a successful analysis. - */ - - import { createHash } from 'crypto'; -@@ -815,6 +815,27 @@ export const packParseCacheChunks = ( - - const LEGACY_CACHE_FILENAME = 'parse-cache.json'; - const CACHE_DIRNAME = 'parse-cache'; -+/** -+ * Per-run staging root for `useParseCache: false`. Parse-cache shards and the -+ * ParsedFile stores write here so a crash cannot mix a new generation into the -+ * live `.gitnexus/parse-cache` / `parsedfile-cache` trees. `saveParseCache` -+ * publishes onto the live `storagePath` only after a successful analysis. -+ */ -+export const COLD_PARSE_REBUILD_DIRNAME = 'parse-rebuild'; -+ -+/** Deterministic staging path — tests only. Production uses {@link createColdParseRebuildDir}. */ -+export const getColdParseRebuildDir = (storagePath: string): string => -+ path.join(storagePath, COLD_PARSE_REBUILD_DIRNAME); -+ -+/** -+ * Unique per analyze process so concurrent `--no-parse-cache` runs on -+ * different branch slots (shared `.gitnexus`, separate index locks) do not -+ * delete each other's staging tree. -+ */ -+export const createColdParseRebuildDir = async (storagePath: string): Promise => { -+ await fs.mkdir(storagePath, { recursive: true }); -+ return fs.mkdtemp(path.join(storagePath, `${COLD_PARSE_REBUILD_DIRNAME}.`)); -+}; - const CACHE_INDEX_FILENAME = 'index.json'; - - /** Keys on disk always come from `computeChunkHash` — 64-char lowercase hex. */ -@@ -851,6 +872,8 @@ export interface ParseCache { - * When set, chunk payloads are loaded from / flushed to sharded files on - * demand instead of retaining every chunk in `entries` for the whole run - * (#1983 — Linux kernel OOM from duplicate in-memory cache + graph). -+ * May be a per-run staging directory (`getColdParseRebuildDir`) while the -+ * live index root is passed separately to `saveParseCache`. - */ - storagePath?: string; - /** Index of chunk hashes known to exist under `storagePath/parse-cache/`. */ -@@ -1034,6 +1057,11 @@ export const loadParseCacheChunk = async ( - */ - const createdCacheDirs = new Set(); - -+/** Drop the mkdir memo after the staging tree is wiped so the next persist recreates it. */ -+export const forgetCreatedParseCacheDir = (storagePath: string): void => { -+ createdCacheDirs.delete(getCacheDirPath(storagePath)); -+}; -+ - /** - * Persist one chunk shard and avoid retaining it in RAM for the rest of the - * run. Falls back to `cache.entries` when `storagePath` is unset (unit tests). -@@ -1171,8 +1199,18 @@ export const saveParseCache = async (storagePath: string, cache: ParseCache): Pr - } - continue; - } -- const existingPath = getCacheChunkPath(storagePath, chunkHash); -- if (await copyV8CacheIfPresent(existingPath, chunkPath)) { -+ // Cold rebuilds persist mid-run under `cache.storagePath` (staging). Prefer -+ // that generation over a same-hash shard still sitting in the live dir so -+ // we never publish a mixed old/new pair. Sibling-branch keys (#2106) that -+ // this run did not rewrite still copy from the live path. -+ const stagedPath = -+ cache.storagePath !== undefined && cache.storagePath !== storagePath -+ ? getCacheChunkPath(cache.storagePath, chunkHash) -+ : undefined; -+ const livePath = getCacheChunkPath(storagePath, chunkHash); -+ const fromStaged = Boolean(stagedPath && cache.onDiskKeys?.has(chunkHash)); -+ const sourcePath = fromStaged && stagedPath ? stagedPath : livePath; -+ if (await copyV8CacheIfPresent(sourcePath, chunkPath)) { - writtenKeys.push(chunkHash); - } - } -@@ -1216,10 +1254,12 @@ export const pruneCache = (cache: ParseCache, usedHashes: ReadonlySet): - return removed; - }; - --const emptyCache = (storagePath?: string): ParseCache => ({ -+export const emptyParseCache = (storagePath?: string): ParseCache => ({ - version: PARSE_CACHE_VERSION, - entries: new Map(), - usedKeys: new Set(), - storagePath, - onDiskKeys: storagePath ? new Set() : undefined, - }); -+ -+const emptyCache = emptyParseCache; -diff --git a/gitnexus/src/storage/parsedfile-store.ts b/gitnexus/src/storage/parsedfile-store.ts -index 51e2a9a54..dce10b03d 100644 ---- a/gitnexus/src/storage/parsedfile-store.ts -+++ b/gitnexus/src/storage/parsedfile-store.ts -@@ -722,3 +722,74 @@ export const pruneAndSaveDurableParsedFileStore = async ( - await fs.writeFile(tmp, JSON.stringify(idx), 'utf-8'); - await fs.rename(tmp, path.join(durableDir, DURABLE_INDEX_FILENAME)); - }; -+ -+/** -+ * Overlay this run's staged durable ParsedFile chunks onto the live store, -+ * then prune the live tree to `keepKeys`. Live chunks this run did not rewrite -+ * (other branches, unused hashes) stay until prune. No-op overlay when the -+ * staged dir is missing. -+ */ -+export const mergeStagedDurableParsedFileStore = async ( -+ liveStoragePath: string, -+ stagedStoragePath: string, -+ version: string, -+ keepKeys: ReadonlySet, -+): Promise => { -+ const liveDir = getDurableParsedFileDir(liveStoragePath); -+ if (stagedStoragePath === liveStoragePath) { -+ await pruneAndSaveDurableParsedFileStore(liveDir, version, keepKeys); -+ return; -+ } -+ const stagedDir = getDurableParsedFileDir(stagedStoragePath); -+ await fs.mkdir(liveDir, { recursive: true }); -+ let stagedEntries: string[] = []; -+ try { -+ stagedEntries = await fs.readdir(stagedDir); -+ } catch { -+ await pruneAndSaveDurableParsedFileStore(liveDir, version, keepKeys); -+ return; -+ } -+ for (const name of stagedEntries) { -+ if (name === DURABLE_INDEX_FILENAME) continue; -+ const from = path.join(stagedDir, name); -+ const to = path.join(liveDir, name); -+ await replaceDurableChunkDir(from, to); -+ } -+ await pruneAndSaveDurableParsedFileStore(liveDir, version, keepKeys); -+}; -+ -+/** Move `from` onto `to` without deleting `to` until the new tree is in place. */ -+const replaceDurableChunkDir = async (from: string, to: string): Promise => { -+ try { -+ await fs.rename(from, to); -+ return; -+ } catch { -+ /* dest exists, or the rename is cross-device */ -+ } -+ const backup = `${to}.replacing`; -+ await fs.rm(backup, { recursive: true, force: true }); -+ let backedUp = false; -+ try { -+ await fs.rename(to, backup); -+ backedUp = true; -+ } catch { -+ /* dest was missing */ -+ } -+ try { -+ try { -+ await fs.rename(from, to); -+ } catch { -+ await fs.cp(from, to, { recursive: true }); -+ await fs.rm(from, { recursive: true, force: true }); -+ } -+ } catch (err) { -+ if (backedUp) { -+ await fs.rm(to, { recursive: true, force: true }).catch(() => {}); -+ await fs.rename(backup, to).catch(() => {}); -+ } -+ throw err; -+ } -+ if (backedUp) { -+ await fs.rm(backup, { recursive: true, force: true }); -+ } -+}; -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..df24e963d 100644 ---- a/gitnexus/test/unit/incremental-orchestration.test.ts -+++ b/gitnexus/test/unit/incremental-orchestration.test.ts -@@ -579,6 +579,28 @@ describe('runFullAnalysis — incremental orchestration', () => { - } - }, 300_000); - -+ it('useParseCache:false bypasses the alreadyUpToDate fast path without --force', async () => { -+ const repo = await setupMiniRepo(); -+ try { -+ const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); -+ await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); -+ -+ const logs: string[] = []; -+ const cold = await runFullAnalysis( -+ repo.dbPath, -+ { skipAgentsMd: true, useParseCache: false }, -+ { onProgress: () => {}, onLog: (message) => logs.push(message) }, -+ ); -+ -+ expect(cold.alreadyUpToDate).toBeUndefined(); -+ expect(cold.pipelineResult?.parseCacheHitFileCount ?? 0).toBe(0); -+ expect(cold.pipelineResult?.reparsedFileCount).toBe(7); -+ expect(logs.join('\n')).toContain('Parser cache bypass requested'); -+ } finally { -+ await repo.cleanup(); -+ } -+ }, 300_000); -+ - it('rebuilds for Actuator snapshots and once more when runtime enrichment is disabled', async () => { - const repo = await setupMiniRepo(); - const runtimeInput = 'runtime-actuator'; -@@ -663,12 +685,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( -+ '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([]); -diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts -index 948dc27e0..f6627e94a 100644 ---- a/gitnexus/test/unit/incremental-parse-cache.test.ts -+++ b/gitnexus/test/unit/incremental-parse-cache.test.ts -@@ -15,6 +15,8 @@ import { - saveParseCache, - pruneCache, - slimParseWorkerResultsForCache, -+ getColdParseRebuildDir, -+ createColdParseRebuildDir, - type ParseCache, - } from '../../src/storage/parse-cache.js'; - import { writeV8CacheFile } from '../../src/storage/v8-sidecar.js'; -@@ -947,4 +949,78 @@ describe('loadParseCache / saveParseCache (round-trip)', () => { - await rm(dir, { recursive: true, force: true }); - } - }); -+ -+ it('persists cold-rebuild shards under staging without touching the live parse-cache dir', async () => { -+ const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-stage-')); -+ try { -+ const liveKey = 'a'.repeat(64); -+ const stagedKey = 'b'.repeat(64); -+ await saveParseCache(dir, { -+ version: PARSE_CACHE_VERSION, -+ entries: new Map([[liveKey, [minimalResult({ fileCount: 1 })]]]), -+ usedKeys: new Set([liveKey]), -+ }); -+ const staging = getColdParseRebuildDir(dir); -+ const cache: ParseCache = { -+ version: PARSE_CACHE_VERSION, -+ entries: new Map(), -+ usedKeys: new Set([liveKey, stagedKey]), -+ storagePath: staging, -+ onDiskKeys: new Set(), -+ }; -+ await persistParseCacheChunk(cache, stagedKey, [minimalResult({ fileCount: 99 })]); -+ const liveNames = await readdir(path.join(dir, 'parse-cache')); -+ expect(liveNames).toContain(`${liveKey}.v8`); -+ expect(liveNames).not.toContain(`${stagedKey}.v8`); -+ const stagedNames = await readdir(path.join(staging, 'parse-cache')); -+ expect(stagedNames).toContain(`${stagedKey}.v8`); -+ -+ const saved = await saveParseCache(dir, cache); -+ expect(saved.sort()).toEqual([liveKey, stagedKey].sort()); -+ const loaded = await loadParseCache(dir); -+ expect((await loadParseCacheChunk(loaded, liveKey))?.[0]?.fileCount).toBe(1); -+ expect((await loadParseCacheChunk(loaded, stagedKey))?.[0]?.fileCount).toBe(99); -+ } finally { -+ await rm(dir, { recursive: true, force: true }); -+ } -+ }); -+ -+ it('prefers a staged shard over a same-hash live shard when publishing', async () => { -+ const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-pref-')); -+ try { -+ const key = 'c'.repeat(64); -+ await saveParseCache(dir, { -+ version: PARSE_CACHE_VERSION, -+ entries: new Map([[key, [minimalResult({ fileCount: 1 })]]]), -+ usedKeys: new Set([key]), -+ }); -+ const staging = getColdParseRebuildDir(dir); -+ const cache: ParseCache = { -+ version: PARSE_CACHE_VERSION, -+ entries: new Map(), -+ usedKeys: new Set([key]), -+ storagePath: staging, -+ onDiskKeys: new Set(), -+ }; -+ await persistParseCacheChunk(cache, key, [minimalResult({ fileCount: 7 })]); -+ await saveParseCache(dir, cache); -+ const loaded = await loadParseCache(dir); -+ expect((await loadParseCacheChunk(loaded, key))?.[0]?.fileCount).toBe(7); -+ } finally { -+ await rm(dir, { recursive: true, force: true }); -+ } -+ }); -+ -+ it('createColdParseRebuildDir returns distinct directories under the same storage root', async () => { -+ const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-uniq-')); -+ try { -+ const a = await createColdParseRebuildDir(dir); -+ const b = await createColdParseRebuildDir(dir); -+ expect(a).not.toBe(b); -+ expect(a.startsWith(path.join(dir, 'parse-rebuild.'))).toBe(true); -+ expect(b.startsWith(path.join(dir, 'parse-rebuild.'))).toBe(true); -+ } finally { -+ await rm(dir, { recursive: true, force: true }); -+ } -+ }); - }); -diff --git a/gitnexus/test/unit/parsedfile-store.test.ts b/gitnexus/test/unit/parsedfile-store.test.ts -index 39fd4c9fe..85a96e3da 100644 ---- a/gitnexus/test/unit/parsedfile-store.test.ts -+++ b/gitnexus/test/unit/parsedfile-store.test.ts -@@ -15,6 +15,9 @@ import { - getParsedFileStoreDir, - getDurableParsedFileDir, - parsedFileLoadGc, -+ prepareDurableParsedFileChunk, -+ pruneAndSaveDurableParsedFileStore, -+ mergeStagedDurableParsedFileStore, - } from '../../src/storage/parsedfile-store.js'; - - /** -@@ -846,4 +849,45 @@ describe('parsedfile-store receiverChain sanitation', () => { - await rm(dir, { recursive: true, force: true }); - } - }); -+ -+ it('overlays staged durable chunks onto the live store without dropping live-only keys', async () => { -+ const live = await mkdtemp(path.join(tmpdir(), 'pf-live-')); -+ const staged = await mkdtemp(path.join(tmpdir(), 'pf-stg-')); -+ try { -+ const liveOnly = '1'.repeat(64); -+ const rewritten = '2'.repeat(64); -+ await prepareDurableParsedFileChunk(getDurableParsedFileDir(live), liveOnly); -+ persistDurableParsedFileShardSync(getDurableParsedFileDir(live), liveOnly, 1, 0, [ -+ makeParsedFile('keep.c'), -+ ]); -+ await prepareDurableParsedFileChunk(getDurableParsedFileDir(live), rewritten); -+ persistDurableParsedFileShardSync(getDurableParsedFileDir(live), rewritten, 1, 0, [ -+ makeParsedFile('old.c'), -+ ]); -+ await pruneAndSaveDurableParsedFileStore( -+ getDurableParsedFileDir(live), -+ 'v-test', -+ new Set([liveOnly, rewritten]), -+ ); -+ -+ await prepareDurableParsedFileChunk(getDurableParsedFileDir(staged), rewritten); -+ persistDurableParsedFileShardSync(getDurableParsedFileDir(staged), rewritten, 1, 0, [ -+ makeParsedFile('new.c'), -+ ]); -+ -+ await mergeStagedDurableParsedFileStore( -+ live, -+ staged, -+ 'v-test', -+ new Set([liveOnly, rewritten]), -+ ); -+ -+ expect(await durableChunkHasShards(live, liveOnly, new Set(['keep.c']))).toBe(true); -+ expect(await durableChunkHasShards(live, rewritten, new Set(['new.c']))).toBe(true); -+ expect(await durableChunkHasShards(live, rewritten, new Set(['old.c']))).toBe(false); -+ } finally { -+ await rm(live, { recursive: true, force: true }); -+ await rm(staged, { recursive: true, force: true }); -+ } -+ }); - }); diff --git a/eval/workflow_bench/review_cases/pr-3153-defect.patch b/eval/workflow_bench/review_cases/pr-3153-defect.patch deleted file mode 100644 index 01e5fe860..000000000 --- a/eval/workflow_bench/review_cases/pr-3153-defect.patch +++ /dev/null @@ -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 - All analyze flags - - ```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 ': '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 ', '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>(); - 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(), -+ storagePath, -+ onDiskKeys: new Set(), -+ } -+ : 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([]); diff --git a/eval/workflow_bench/review_scoring.py b/eval/workflow_bench/review_scoring.py index b3fe8b6f6..128f119ed 100644 --- a/eval/workflow_bench/review_scoring.py +++ b/eval/workflow_bench/review_scoring.py @@ -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, diff --git a/eval/workflow_bench/runner.py b/eval/workflow_bench/runner.py index a287fe601..e000f5622 100644 --- a/eval/workflow_bench/runner.py +++ b/eval/workflow_bench/runner.py @@ -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()] diff --git a/eval/workflow_bench/tasks.review.scenarios.yaml b/eval/workflow_bench/tasks.review.scenarios.yaml index 352998510..5c0de3cda 100644 --- a/eval/workflow_bench/tasks.review.scenarios.yaml +++ b/eval/workflow_bench/tasks.review.scenarios.yaml @@ -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