mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
Merge pull request #2697 from abhigyanpatwari/sync/main-to-main-aptos
Merge main into main-aptos (schema v16)
This commit is contained in:
commit
304aa00a8a
211 changed files with 14033 additions and 1116 deletions
|
|
@ -81,6 +81,18 @@ list_repos { offset: 400 } → repos 401–437, hasMore false
|
|||
|
||||
Notes: `offset` ≥ `total` returns an empty page (with `total` still reported). Out-of-range or malformed `limit`/`offset` (non-integer, `limit` outside `[1, 200]`, `offset < 0`) are rejected with a clear error — `limit` above the max is rejected, not silently capped. The order is deterministic (lower-cased name, then path), so paging never skips or duplicates an entry while the registry is unchanged.
|
||||
|
||||
### Inline staleness signal (`query` / `context` / `impact` / `cypher`)
|
||||
|
||||
These four hot read tools attach a non-blocking `staleness` field to their response when the index is behind the checkout's current HEAD — the same `{ commitsBehind, hint }` shape `list_repos` already reports — so a direct tool call surfaces a behind-HEAD index without a separate `list_repos` call:
|
||||
|
||||
```jsonc
|
||||
{ /* …the tool's normal result… */
|
||||
"staleness": { "commitsBehind": 3, "hint": "⚠️ Index is 3 commits behind HEAD. Run analyze tool to update." }
|
||||
}
|
||||
```
|
||||
|
||||
The field is **absent when the index is current** (or when the freshness check can't run), so its presence is the signal. It is only ever added to object results — raw-array `cypher` output and error envelopes are returned unchanged. `@group`-targeted calls do not carry it (multi-repo staleness is ill-defined). When you see it, the graph may be behind the working tree — re-run `analyze` before trusting blast-radius or dependence answers.
|
||||
|
||||
### Taint findings (`explain`)
|
||||
|
||||
`explain` returns taint findings recorded by `gitnexus analyze --pdg` — intra-procedural `TAINTED` edges plus cross-function `TAINT_PATH` hops where the interprocedural taint phase found a function-level source→sink chain. Each finding includes a sink category (command-injection, code-injection, path-traversal, sql-injection, xss), source/sink lines, and the ordered hop path with the variable carried on each hop.
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ ENV BUN_VERSION=${BUN_VERSION} \
|
|||
TZ=${TZ} \
|
||||
DEVCONTAINER=true \
|
||||
NODE_OPTIONS=--max-old-space-size=4096 \
|
||||
GITNEXUS_AUTO_HEAP=0 \
|
||||
POWERLEVEL9K_DISABLE_GITSTATUS=true
|
||||
|
||||
# Native build toolchain that gitnexus/postinstall needs. It compiles
|
||||
|
|
|
|||
|
|
@ -352,7 +352,7 @@ jobs:
|
|||
with:
|
||||
persist-credentials: false # this job uploads artifacts (artipacked)
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
|
|
|
|||
4
.github/workflows/ci-devcontainer.yml
vendored
4
.github/workflows/ci-devcontainer.yml
vendored
|
|
@ -39,7 +39,7 @@ jobs:
|
|||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Unit-test the host->container config transforms
|
||||
|
|
@ -60,7 +60,7 @@ jobs:
|
|||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
# Builds the image the same way a developer's "Reopen in Container" does.
|
||||
|
|
|
|||
4
.github/workflows/ci-quality.yml
vendored
4
.github/workflows/ci-quality.yml
vendored
|
|
@ -14,7 +14,7 @@ jobs:
|
|||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
|
@ -29,7 +29,7 @@ jobs:
|
|||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
|
|
|||
7
.github/workflows/ci-tests.yml
vendored
7
.github/workflows/ci-tests.yml
vendored
|
|
@ -428,7 +428,7 @@ jobs:
|
|||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
|
|
@ -446,7 +446,7 @@ jobs:
|
|||
# Switch to the engines-floor Node AFTER building — native deps built on
|
||||
# 22.x load across the whole 22.x ABI line, and nothing installs after this
|
||||
# (so no package-manager cache is needed).
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22.18.0'
|
||||
package-manager-cache: false
|
||||
|
|
@ -549,6 +549,7 @@ jobs:
|
|||
npx vitest run --no-file-parallelism
|
||||
test/integration/cobol-pipeline-benchmark.test.ts
|
||||
test/integration/csharp-pipeline-benchmark.test.ts
|
||||
test/integration/instance-ownership-pipeline-benchmark.test.ts
|
||||
test/integration/rust-pipeline-benchmark.test.ts
|
||||
test/integration/php-pipeline-benchmark.test.ts
|
||||
test/integration/ruby-pipeline-benchmark.test.ts
|
||||
|
|
@ -587,7 +588,7 @@ jobs:
|
|||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22.18.0'
|
||||
cache: npm
|
||||
|
|
|
|||
2
.github/workflows/gitnexus-review-agent.yml
vendored
2
.github/workflows/gitnexus-review-agent.yml
vendored
|
|
@ -323,7 +323,7 @@ jobs:
|
|||
- name: Set up pinned Node.js
|
||||
id: setup-node
|
||||
if: steps.context.outputs.ready == 'true'
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22.18.0'
|
||||
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ jobs:
|
|||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22.18.0'
|
||||
cache: npm
|
||||
|
|
|
|||
2
.github/workflows/grammar-update-monitor.yml
vendored
2
.github/workflows/grammar-update-monitor.yml
vendored
|
|
@ -48,7 +48,7 @@ jobs:
|
|||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
|
|
|
|||
2
.github/workflows/pr-autofix.yml
vendored
2
.github/workflows/pr-autofix.yml
vendored
|
|
@ -59,7 +59,7 @@ jobs:
|
|||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
|
|
|||
4
.github/workflows/publish.yml
vendored
4
.github/workflows/publish.yml
vendored
|
|
@ -395,7 +395,7 @@ jobs:
|
|||
exit 1
|
||||
fi
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
# Node 24 ships with npm >= 11.5.x, which is the minimum that
|
||||
# supports npm Trusted Publishing OIDC. Node 22 ships with npm
|
||||
|
|
@ -881,7 +881,7 @@ jobs:
|
|||
fi
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v2
|
||||
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v2
|
||||
with:
|
||||
tag_name: ${{ steps.vtag-gate.outputs.vtag }}
|
||||
name: >-
|
||||
|
|
|
|||
2
.github/workflows/skill-sync.yml
vendored
2
.github/workflows/skill-sync.yml
vendored
|
|
@ -50,7 +50,7 @@ jobs:
|
|||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ Maintainer may widen scope per task.
|
|||
3. **Run impact analysis before editing shared symbols** — `impact` (upstream) for functions/classes/methods others call. Do not ignore HIGH/CRITICAL without maintainer sign-off.
|
||||
4. **Run `detect_changes` before commit** — confirm diffs map to expected symbols/processes when the graph is available.
|
||||
5. **Preserve embeddings** — plain `npx gitnexus analyze` now preserves any embeddings recorded in the index metadata (`.gitnexus/gitnexus.json`, mirrored to the legacy `meta.json`) — the previous behavior wiped them. Use `--embeddings` to also generate vectors for new/changed nodes; use `--drop-embeddings` only when an explicit wipe is intended (e.g., model swap).
|
||||
6. **Never `terminate()` a worker that may be inside a native call** — killing a worker thread mid-N-API aborts the entire process (`Napi::Error` → `std::terminate` → SIGABRT, #2432), so a timeout meant to trigger a graceful fallback takes the whole run down instead. Any worker running native code (tree-sitter grammars, LadybugDB, Icebug) must either reach a JS-visible safe point first — the parse pool's `shutdownDrainMs` handshake in `src/core/ingestion/workers/worker-pool.ts` — or be abandoned with `unref()` and left to exit on its own. A one-shot worker that ends after a single `postMessage` needs no `terminate()` at all: it exits by itself. This bites hardest on the path you cannot test locally, because the abort only reproduces once the native module actually loads.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ and the caller supplied none of `target_uid` / `file_path` / `kind`,
|
|||
"message": "Found N symbols matching '<target>'. Use target_uid, file_path, or kind to disambiguate.",
|
||||
"target": { "name": "<target>" },
|
||||
"direction": "upstream",
|
||||
"impactedCount": 0,
|
||||
"impactedCount": null,
|
||||
"risk": "UNKNOWN",
|
||||
"candidates": [
|
||||
{ "uid": "...", "name": "...", "kind": "Function", "filePath": "...", "line": 42, "score": 0.76 }
|
||||
|
|
@ -25,6 +25,13 @@ and the caller supplied none of `target_uid` / `file_path` / `kind`,
|
|||
}
|
||||
```
|
||||
|
||||
> `impactedCount` is `null`, not `0`, on an ambiguous result (#2687): no single
|
||||
> symbol was resolved, so the blast radius is *undetermined*. A numeric `0` was
|
||||
> indistinguishable from a genuine "nothing depends on this", so a caller
|
||||
> testing `impactedCount === 0` read a false all-clear. Read `maxImpactedCount`
|
||||
> (callgraph ambiguity) or the per-candidate counts in `candidates[]` for the
|
||||
> real figure. Callers written as `impactedCount || 0` are unaffected.
|
||||
|
||||
### Do I need to migrate?
|
||||
|
||||
**Probably not, but check for assumptions.** Callers that unconditionally
|
||||
|
|
|
|||
10
README.md
10
README.md
|
|
@ -181,7 +181,7 @@ flowchart TB
|
|||
| `detect_impact` | Pre-commit change analysis — scope, affected processes, risk level |
|
||||
| `generate_map` | Architecture documentation from the knowledge graph with mermaid diagrams |
|
||||
|
||||
### Agent skills installed to `.claude/skills/` automatically
|
||||
### Agent skills installed to `.claude/skills/` and `.agents/skills/` (if `.agents/` exists) automatically
|
||||
|
||||
- **Exploring** — navigate unfamiliar code using the knowledge graph
|
||||
- **Debugging** — trace bugs through call chains
|
||||
|
|
@ -198,6 +198,8 @@ flowchart TB
|
|||
|
||||
**Repo-specific skills** — run `gitnexus analyze --skills` and GitNexus detects the functional areas of your codebase (via Leiden community detection) and generates each one as a direct project skill under `.claude/skills/gitnexus-area-<name>/`. Each skill describes a module's key files, entry points, execution flows, and cross-area connections, and is regenerated on each `--skills` run to stay current.
|
||||
|
||||
When a repo contains an `.agents/` directory, the standard and generated skills are also mirrored to `.agents/skills/` (e.g. `.agents/skills/gitnexus-cli/`, `.agents/skills/gitnexus-area-<name>/`) so agents that read repo-local `.agents/skills/` (like Codex) stay in sync.
|
||||
|
||||
## Editor Setup
|
||||
|
||||
`gitnexus setup` auto-detects your editors and writes the correct global MCP config. Run it once. To configure only selected integrations, pass `--coding-agent`/`-c` with a comma-separated list, e.g. `gitnexus setup -c cursor,codex`.
|
||||
|
|
@ -395,7 +397,7 @@ gitnexus analyze --skills # Generate repo-specific skill files from detec
|
|||
gitnexus analyze --skip-embeddings # Skip embedding generation (faster)
|
||||
gitnexus analyze --embeddings [limit] # Enable embedding generation (slower, better search)
|
||||
gitnexus analyze --skip-agents-md # Preserve custom AGENTS.md/CLAUDE.md gitnexus section edits
|
||||
gitnexus analyze --skip-skills # Skip installing standard .claude/skills/gitnexus-* skill files
|
||||
gitnexus analyze --skip-skills # Skip installing standard skill files under .claude/skills/ and .agents/skills/
|
||||
gitnexus analyze --skip-git # Index folders that are not Git repositories
|
||||
gitnexus analyze --default-branch develop # Branch used in the generated regression-compare example (base_ref)
|
||||
gitnexus analyze --verbose # Log skipped files when parsers are unavailable
|
||||
|
|
@ -451,7 +453,7 @@ Commit a `.gitnexusrc` JSON file at the repo root to preconfigure recurring `ana
|
|||
// over its fix on every analyze. (Alias: "branch".)
|
||||
"defaultBranch": "develop",
|
||||
"skipContextFiles": true, // alias of skipAgentsMd: keep your own AGENTS.md/CLAUDE.md
|
||||
"skipSkills": true, // don't install standard .claude/skills/gitnexus-* skills
|
||||
"skipSkills": true, // don't install standard skill files under .claude/skills/ and .agents/skills/
|
||||
"embeddings": true, // generate embeddings by default
|
||||
"workerTimeout": 60,
|
||||
}
|
||||
|
|
@ -514,7 +516,7 @@ Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max
|
|||
| `GITNEXUS_WORKER_READY_TIMEOUT_MS` | `5000` | Startup budget in milliseconds for a parse worker to load its grammar bindings and report `{type:'ready'}`. Slots that miss it are treated as startup crashes. | Slow or heavily loaded hosts where a full pool cold-starting concurrently needs more than 5s, and analyze aborts with "did not report ready within 5000ms". |
|
||||
| `GITNEXUS_FTS_STEMMER` | `porter` | Stemmer used when rebuilding BM25/FTS indexes. Use `none` for CJK-heavy repositories, or a language stemmer such as `german`, `french`, or `spanish` for matching repository comments. Re-run `gitnexus analyze --repair-fts` after changing it. | Keyword search quality is poor for non-English comments or identifiers under English stemming. |
|
||||
| `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` | `67108864` (64 MiB) | LadybugDB WAL auto-checkpoint threshold in bytes. Equivalent to `--wal-checkpoint-threshold <bytes>`. `-1` keeps LadybugDB's stock threshold (~16 MiB). Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. | You need a larger or smaller WAL auto-checkpoint threshold for your analyze workload. |
|
||||
| `GITNEXUS_LBUG_BUFFER_POOL_SIZE` | min(2 GiB, 80% RAM) | LadybugDB buffer-pool ceiling in bytes for every GitNexus database (analyze, MCP server, serve, group bridges). `0` restores LadybugDB's native unbounded default of 80% of system RAM; invalid values warn and fall back to the default (#2557). | A long-lived `gitnexus mcp` or a big incremental `analyze` uses too much memory, or a huge repo's working set genuinely needs a pool larger than 2 GiB. |
|
||||
| `GITNEXUS_LBUG_BUFFER_POOL_SIZE` | min(2 GiB, 80% RAM) | LadybugDB buffer-pool ceiling in bytes for every GitNexus database (analyze, MCP server, serve, group bridges). `0` restores LadybugDB's native unbounded default of 80% of system RAM; invalid values warn and fall back to the default (#2557). During `analyze` the pool is right-sized to the graph, scaled on non-4 KiB-page hosts by the page-size granule ratio up to min(2 GiB × pageSize/4 KiB, 80% RAM) (#2631); this env var overrides all of that as an absolute value. | A long-lived `gitnexus mcp` or a big incremental `analyze` uses too much memory, or a huge repo's working set genuinely needs a pool larger than 2 GiB. |
|
||||
| `GITNEXUS_LBUG_MAX_DB_SIZE` | `17179869184` (16 GiB) | Maximum size in bytes of a single LadybugDB database file — an mmap/disk-address-space ceiling, not a memory limit (it does not constrain the buffer pool). Invalid values silently fall back to the default. | Indexing a genuinely huge monorepo whose on-disk graph index approaches 16 GiB. |
|
||||
| `GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES` | `8388608` (8 MB) | Per-job byte budget the pool will send to a worker in one `postMessage`. | Very large individual files; mostly diagnostic — bumping past 8 MB risks structured-clone memory pressure. |
|
||||
| `GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT` | `3` | Max replacement spawns per worker slot before the slot is dropped from the active rotation. Bounds respawn loops on a chronically-crashing slot. | Hosts where a flaky worker should retry more (raise) or fail-fast (lower) before the slot is dropped. |
|
||||
|
|
|
|||
2
eval/workflow_bench/learnings.jsonl
Normal file
2
eval/workflow_bench/learnings.jsonl
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
{"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."}
|
||||
|
|
@ -81,6 +81,18 @@ list_repos { offset: 400 } → repos 401–437, hasMore false
|
|||
|
||||
Notes: `offset` ≥ `total` returns an empty page (with `total` still reported). Out-of-range or malformed `limit`/`offset` (non-integer, `limit` outside `[1, 200]`, `offset < 0`) are rejected with a clear error — `limit` above the max is rejected, not silently capped. The order is deterministic (lower-cased name, then path), so paging never skips or duplicates an entry while the registry is unchanged.
|
||||
|
||||
### Inline staleness signal (`query` / `context` / `impact` / `cypher`)
|
||||
|
||||
These four hot read tools attach a non-blocking `staleness` field to their response when the index is behind the checkout's current HEAD — the same `{ commitsBehind, hint }` shape `list_repos` already reports — so a direct tool call surfaces a behind-HEAD index without a separate `list_repos` call:
|
||||
|
||||
```jsonc
|
||||
{ /* …the tool's normal result… */
|
||||
"staleness": { "commitsBehind": 3, "hint": "⚠️ Index is 3 commits behind HEAD. Run analyze tool to update." }
|
||||
}
|
||||
```
|
||||
|
||||
The field is **absent when the index is current** (or when the freshness check can't run), so its presence is the signal. It is only ever added to object results — raw-array `cypher` output and error envelopes are returned unchanged. `@group`-targeted calls do not carry it (multi-repo staleness is ill-defined). When you see it, the graph may be behind the working tree — re-run `analyze` before trusting blast-radius or dependence answers.
|
||||
|
||||
### Taint findings (`explain`)
|
||||
|
||||
`explain` returns taint findings recorded by `gitnexus analyze --pdg` — intra-procedural `TAINTED` edges plus cross-function `TAINT_PATH` hops where the interprocedural taint phase found a function-level source→sink chain. Each finding includes a sink category (command-injection, code-injection, path-traversal, sql-injection, xss), source/sink lines, and the ordered hop path with the variable carried on each hop.
|
||||
|
|
|
|||
|
|
@ -180,14 +180,14 @@ export type RelationshipType =
|
|||
| 'ENTRY_POINT_OF'
|
||||
| 'WRAPS'
|
||||
| 'QUERIES'
|
||||
/** Dependency-injection edge: a consumer class receives every implementer
|
||||
* of interface `T` via a container-injected collection-typed field
|
||||
* (`List<T>`, `Set<T>`, `Collection<T>`, or `Map<K,T>`). Precondition: the
|
||||
* field carries an injection annotation recognized by a per-language
|
||||
* matcher registered in `di-extractors/` (Java/Spring today: `@Autowired`
|
||||
* or `@Inject`; `@Resource` is excluded — by-name-first semantics).
|
||||
* Source = the consumer Class node (the one owning the field).
|
||||
* Target = an implementing Class node.
|
||||
/** Dependency-injection edge: a consumer class receives a likely provider
|
||||
* through constructor, field, method, or collection injection. A
|
||||
* per-language resolver identifies the site and provider metadata; the
|
||||
* shared DI phase uses type heritage, qualifier names, and preferred
|
||||
* provider markers to resolve it. Ambiguous single injection is represented
|
||||
* by multiple lower-confidence edges instead of a fabricated exact target.
|
||||
* Source = the consumer Class node (the one owning the injection site).
|
||||
* Target = a concrete provider Class node.
|
||||
* Framework specifics live in the `reason` payload (e.g.
|
||||
* `Spring DI: @Autowired List<T>`), not in this type contract.
|
||||
* Lets Cypher queries trace which beans the container injects into a given
|
||||
|
|
|
|||
|
|
@ -351,6 +351,11 @@ export interface BindingRef {
|
|||
readonly origin: 'local' | 'import' | 'namespace' | 'wildcard' | 'reexport';
|
||||
/** Non-null for non-local origins; carries the `ImportEdge` that brought the name into this scope. */
|
||||
readonly via?: ImportEdge;
|
||||
/**
|
||||
* Optional semantic visibility evidence supplied by a language hook.
|
||||
* Shared resolution consumes this without inspecting language syntax.
|
||||
*/
|
||||
readonly visibility?: 'static-member-import';
|
||||
}
|
||||
|
||||
// ─── §2.5 TypeRef ───────────────────────────────────────────────────────────
|
||||
|
|
|
|||
144
gitnexus-web/package-lock.json
generated
144
gitnexus-web/package-lock.json
generated
|
|
@ -11,7 +11,7 @@
|
|||
"@langchain/anthropic": "^1.5.1",
|
||||
"@langchain/core": "^1.2.2",
|
||||
"@langchain/google-genai": "^2.2.0",
|
||||
"@langchain/langgraph": "^1.4.7",
|
||||
"@langchain/langgraph": "^1.4.8",
|
||||
"@langchain/ollama": "^1.3.0",
|
||||
"@langchain/openai": "^1.5.3",
|
||||
"@sigma/edge-curve": "^3.1.0",
|
||||
|
|
@ -29,14 +29,14 @@
|
|||
"i18next": "^26.3.0",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"langchain": "^1.4.6",
|
||||
"lru-cache": "^11.5.1",
|
||||
"lru-cache": "^11.5.2",
|
||||
"lucide-react": "^1.23.0",
|
||||
"mermaid": "^11.15.0",
|
||||
"mnemonist": "^0.40.4",
|
||||
"pandemonium": "^2.4.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-i18next": "^17.0.10",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"react-zoom-pan-pinch": "^4.0.3",
|
||||
|
|
@ -47,7 +47,7 @@
|
|||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/types": "^7.29.0",
|
||||
"@babel/types": "^8.0.0",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
|
|
@ -63,7 +63,7 @@
|
|||
"jsdom": "^29.1.1",
|
||||
"tree-sitter-wasms": "^0.1.13",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^8.1.4",
|
||||
"vite": "^8.1.5",
|
||||
"vitest": "^4.1.10",
|
||||
"wait-on": "^9.0.10"
|
||||
},
|
||||
|
|
@ -186,13 +186,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"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==",
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz",
|
||||
"integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
"node": "^22.18.0 || >=24.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
|
|
@ -221,16 +221,17 @@
|
|||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
||||
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
|
||||
"node_modules/@babel/parser/node_modules/@babel/helper-string-parser": {
|
||||
"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": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"node_modules/@babel/parser/node_modules/@babel/types": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
|
||||
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
|
||||
|
|
@ -244,6 +245,39 @@
|
|||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
||||
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.0.tgz",
|
||||
"integrity": "sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^8.0.0",
|
||||
"@babel/helper-validator-identifier": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.18.0 || >=24.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types/node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "8.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz",
|
||||
"integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^22.18.0 || >=24.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@bcoe/v8-coverage": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
|
||||
|
|
@ -1138,13 +1172,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@langchain/langgraph": {
|
||||
"version": "1.4.7",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-1.4.7.tgz",
|
||||
"integrity": "sha512-2tcyf3QGC7v89kqSxMCtRvzg/3L/4yHtOaWC49A8KieCciWJs7LGaxHoPB6QRxXyUgyR+Zg9Q1ss/XJIE+JuSQ==",
|
||||
"version": "1.4.8",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-1.4.8.tgz",
|
||||
"integrity": "sha512-DN1Np1XefdBEbp1qBKlt39cwoL743AAGpR5Ipja0gY2YbWvsoQnOTIrjnj/orSAhaUYsdTKS8VSWdFzsHZo6Ig==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@langchain/langgraph-checkpoint": "^1.1.3",
|
||||
"@langchain/langgraph-sdk": "~1.9.25",
|
||||
"@langchain/langgraph-sdk": "~1.9.26",
|
||||
"@langchain/protocol": "^0.0.18",
|
||||
"@standard-schema/spec": "1.1.0"
|
||||
},
|
||||
|
|
@ -1169,9 +1203,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@langchain/langgraph-sdk": {
|
||||
"version": "1.9.25",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.9.25.tgz",
|
||||
"integrity": "sha512-mRKW8zyQUaHox+HirRFMRrPqOvNbQI3xeXDt6kkk4PbBg77V92bsO1WzUVNrmJ81zCkvxyOrWSK8D6ioCj0a8A==",
|
||||
"version": "1.9.28",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.9.28.tgz",
|
||||
"integrity": "sha512-4j3XuM0PvtmAbL8mPfBS99ez3+ytRfgbOpAR/nOeaejTRF3Q9dNw2QnaGLGng8wLPtGLoSj+SYgUOVxy9Bv9vg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@langchain/protocol": "^0.0.18",
|
||||
|
|
@ -1208,9 +1242,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@langchain/langgraph-sdk/node_modules/p-queue": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.3.0.tgz",
|
||||
"integrity": "sha512-7NED7xhQ74Ngp4JP/2e0VZHp7vSWfJfqeiR92jPgxsz6m0Se4P03YoTKa9dDXyZ3r6P616gUXttrB6nnHYKang==",
|
||||
"version": "9.3.3",
|
||||
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.3.3.tgz",
|
||||
"integrity": "sha512-NXAOdnEe5FsZJfT4oK84lE1Y5cFFdWlRuOo5tww8DyNMxyRXwn39fIkUtNLKppcPC+UYU/bXujNCUGDv01y7CA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"eventemitter3": "^5.0.4",
|
||||
|
|
@ -5672,9 +5706,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "11.5.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
|
||||
"integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
|
||||
"version": "11.5.2",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
|
||||
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
|
|
@ -5721,6 +5755,30 @@
|
|||
"source-map-js": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/magicast/node_modules/@babel/helper-string-parser": {
|
||||
"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": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/magicast/node_modules/@babel/types": {
|
||||
"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.29.7",
|
||||
"@babel/helper-validator-identifier": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/make-dir": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
|
||||
|
|
@ -6826,9 +6884,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.15",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
|
||||
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
|
||||
"version": "3.3.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
|
@ -7216,9 +7274,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.16",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
|
||||
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
|
||||
"version": "8.5.22",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz",
|
||||
"integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
|
|
@ -7235,7 +7293,7 @@
|
|||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.12",
|
||||
"nanoid": "^3.3.16",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
|
|
@ -7356,9 +7414,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "17.0.8",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz",
|
||||
"integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==",
|
||||
"version": "17.0.10",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.10.tgz",
|
||||
"integrity": "sha512-XneHftyYA774MJkkccSkZ5oKrUpCnXIPmxio3wemqrVzCRLWiGXOMbIzObrer03fNDEnm8g8R5yYls4HcE+esg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2",
|
||||
|
|
@ -7368,7 +7426,7 @@
|
|||
"peerDependencies": {
|
||||
"i18next": ">= 26.2.0",
|
||||
"react": ">= 16.8.0",
|
||||
"typescript": "^5 || ^6"
|
||||
"typescript": "^5 || ^6 || ^7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
|
|
@ -8296,15 +8354,15 @@
|
|||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.1.4",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz",
|
||||
"integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==",
|
||||
"version": "8.1.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
|
||||
"integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.5",
|
||||
"postcss": "^8.5.16",
|
||||
"rolldown": "~1.1.4",
|
||||
"postcss": "^8.5.17",
|
||||
"rolldown": "~1.1.5",
|
||||
"tinyglobby": "^0.2.17"
|
||||
},
|
||||
"bin": {
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
"@langchain/anthropic": "^1.5.1",
|
||||
"@langchain/core": "^1.2.2",
|
||||
"@langchain/google-genai": "^2.2.0",
|
||||
"@langchain/langgraph": "^1.4.7",
|
||||
"@langchain/langgraph": "^1.4.8",
|
||||
"@langchain/ollama": "^1.3.0",
|
||||
"@langchain/openai": "^1.5.3",
|
||||
"@sigma/edge-curve": "^3.1.0",
|
||||
|
|
@ -39,14 +39,14 @@
|
|||
"i18next": "^26.3.0",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"langchain": "^1.4.6",
|
||||
"lru-cache": "^11.5.1",
|
||||
"lru-cache": "^11.5.2",
|
||||
"lucide-react": "^1.23.0",
|
||||
"mermaid": "^11.15.0",
|
||||
"mnemonist": "^0.40.4",
|
||||
"pandemonium": "^2.4.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-i18next": "^17.0.10",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"react-zoom-pan-pinch": "^4.0.3",
|
||||
|
|
@ -57,7 +57,7 @@
|
|||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/types": "^7.29.0",
|
||||
"@babel/types": "^8.0.0",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
|
|
@ -73,7 +73,7 @@
|
|||
"jsdom": "^29.1.1",
|
||||
"tree-sitter-wasms": "^0.1.13",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^8.1.4",
|
||||
"vite": "^8.1.5",
|
||||
"vitest": "^4.1.10",
|
||||
"wait-on": "^9.0.10"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -165,13 +165,20 @@ The result is a **LadybugDB graph database** stored locally in `.gitnexus/` with
|
|||
|
||||
### Experimental community detection engine
|
||||
|
||||
Community detection uses the bundled Graphology Leiden implementation by default. To test the #2337 Icebug migration path without changing default analyze behavior, set:
|
||||
> **Experimental — not supported for production indexes.** The Icebug engine is a research path for #2337. It carries no stability guarantee, may change or be removed without a major version, and partitions differently from the default, so switching engines changes community IDs and any generated context keyed on them. Reindex with `graphology` before relying on the output.
|
||||
|
||||
Community detection uses the bundled Graphology Leiden implementation by default. To try the #2337 Icebug path without changing default analyze behavior, install the optional native package alongside GitNexus and set the engine:
|
||||
|
||||
```bash
|
||||
npm i @ladybugmem/icebug
|
||||
GITNEXUS_COMMUNITY_ENGINE=icebug npx gitnexus analyze
|
||||
```
|
||||
|
||||
Supported values are `graphology`, `icebug`, and `auto`. The Icebug path is an experimental probe: GitNexus does not bundle an Icebug native package yet, and if a separately resolvable module is unavailable or its API does not match the expected `Graph.fromCSR` / `ParallelLeidenView` shape, analyze falls back to Graphology and reports the fallback in progress output. Today `auto` is behaviorally identical to `icebug`: both try Icebug and fall back to Graphology, while `graphology` skips the Icebug probe entirely.
|
||||
Supported values are `graphology`, `icebug`, and `auto`. Today `auto` is behaviorally identical to `icebug`: both try Icebug and fall back to Graphology, while `graphology` skips Icebug entirely.
|
||||
|
||||
Icebug is **not** a declared dependency — its prebuilds link against system Arrow 24 (`libarrow.so.2400`), OpenMP, and glibc ≥ 2.38, none of which GitNexus can assume. Analyze falls back to Graphology and reports the reason in progress output when the module is missing, fails to load, or predates the `setNumberOfThreads` / `setSeed` controls that reproducible community IDs require (present at [icebug-nodejs](https://github.com/Ladybug-Memory/icebug-nodejs) HEAD, absent from the published 12.8.0 tarball — so the fallback is what you will see today). The engine is pinned to `threads: 1`, `randomize: false` for determinism.
|
||||
|
||||
Note that the bundled Graphology path is no longer the slow option it once was: #2337 removed an accidental O(communities × N) copy in the vendored Leiden. On a synthetic 200k-node / 800k-edge benchmark graph it went from exceeding the 60s timeout to finishing in ~15s. Real projections vary with their degree distribution, so treat that as a direction, not a guarantee.
|
||||
|
||||
## MCP Tools
|
||||
|
||||
|
|
@ -354,6 +361,13 @@ Installed automatically by both `gitnexus analyze` (per-repo) and `gitnexus setu
|
|||
|
||||
- Node.js >= 22
|
||||
- Git repository (uses git for commit tracking)
|
||||
- **Linux: glibc 2.34 or newer** (Ubuntu 22.04+, RHEL/Rocky/Alma 9+, Debian 12+, Fedora 35+). The
|
||||
LadybugDB native binary ships as a prebuild against that floor, so on an older host it cannot
|
||||
load and reinstalling does not help — see
|
||||
[Linux: `GLIBC_2.34' not found`](#linux-glibc_234-not-found).
|
||||
- **Windows, for full-text search:** the Microsoft Visual C++ 2015-2022 Redistributable (x64) *and*
|
||||
OpenSSL 3 (`libssl-3-x64.dll`, `libcrypto-3-x64.dll`) resolvable on `PATH` — see
|
||||
[Windows: full-text search unavailable](#windows-full-text-search-unavailable).
|
||||
|
||||
## Aptos / Move builds (`aptos` dist-tag)
|
||||
|
||||
|
|
@ -481,6 +495,50 @@ pnpm add -g --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=t
|
|||
gitnexus serve
|
||||
```
|
||||
|
||||
### Linux: `GLIBC_2.34' not found`
|
||||
|
||||
```
|
||||
LadybugDB native binary (lbugjs.node) exists but failed to load:
|
||||
/lib64/libc.so.6: version `GLIBC_2.34' not found (required by .../lbugjs.node)
|
||||
```
|
||||
|
||||
The LadybugDB addon ships as a prebuilt binary compiled against **glibc 2.34**. If your
|
||||
distribution is older (CentOS/RHEL 8 has 2.28, Ubuntu 20.04 has 2.31, Debian 11 has 2.31), the
|
||||
dynamic loader cannot resolve its symbols.
|
||||
|
||||
**Reinstalling does not help** — every download delivers the same prebuilt binary. The fix is a
|
||||
newer C library:
|
||||
|
||||
- Run GitNexus on a distribution with glibc 2.34 or newer — Ubuntu 22.04+, RHEL/Rocky/Alma 9+,
|
||||
Debian 12+, Fedora 35+.
|
||||
- Or run it in the container image, which bundles a current glibc (see [Docker](#docker)).
|
||||
|
||||
`gitnexus doctor` reports the required and detected glibc versions when this happens
|
||||
([#2672](https://github.com/abhigyanpatwari/GitNexus/issues/2672)).
|
||||
|
||||
### Windows: full-text search unavailable
|
||||
|
||||
`analyze` completes, but keyword search is degraded and `doctor` shows the FTS extension failing
|
||||
with Windows error 126 (`The specified module could not be found`). The extension needs two
|
||||
runtime dependencies Windows does not ship by default:
|
||||
|
||||
1. **Microsoft Visual C++ 2015-2022 Redistributable (x64)** —
|
||||
<https://aka.ms/vs/17/release/vc_redist.x64.exe>
|
||||
2. **OpenSSL 3** — `libssl-3-x64.dll` and `libcrypto-3-x64.dll`, resolvable on `PATH`
|
||||
|
||||
The redistributable alone is **not** sufficient. If Git for Windows is installed you already have
|
||||
the OpenSSL DLLs — run `gitnexus` from **Git Bash**, or prepend the directory to `PATH` in the
|
||||
shell you use:
|
||||
|
||||
```powershell
|
||||
$env:PATH = "C:\Program Files\Git\mingw64\bin;$env:PATH"
|
||||
gitnexus analyze --repair-fts
|
||||
```
|
||||
|
||||
Without them the index is still built, but without search tables, so `query` returns empty keyword
|
||||
results until you re-run `gitnexus analyze --repair-fts` from a shell where the DLLs resolve
|
||||
([#2669](https://github.com/abhigyanpatwari/GitNexus/issues/2669)).
|
||||
|
||||
### Installation fails with native module errors
|
||||
|
||||
Some optional language grammars (Dart, Proto, Swift, Kotlin) require native compilation. If they fail, GitNexus still works — those languages will be skipped. To skip them intentionally (no C++ toolchain needed), set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` before installing. Strict `=1` also disables automatic `move-flow` downloads during analysis; a verified cache entry, explicit `MOVE_FLOW`, or compatible binary on `PATH` remains usable.
|
||||
|
|
@ -541,16 +599,17 @@ GitNexus uses optional DuckDB extensions for BM25 and vector search. The `gitnex
|
|||
|
||||
Configure the behavior with these environment variables:
|
||||
|
||||
| Variable | Values | Default | Effect |
|
||||
| -------------------------------------------- | ------------------------------ | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GITNEXUS_LBUG_EXTENSION_INSTALL` | `auto`, `load-only`, `never` | `auto` | `auto` runs one bounded install if LOAD fails — a plain `INSTALL`, escalating to `FORCE INSTALL` only when the LOAD error shows the present extension file is broken. `load-only` only uses already-installed extensions (recommended for offline / firewalled environments). `never` skips optional extensions entirely. |
|
||||
| `GITNEXUS_LBUG_EXTENSION_INSTALL_TIMEOUT_MS` | positive integer | `15000` | Wall-clock budget for the out-of-process extension-install child before it is killed. |
|
||||
| `GITNEXUS_FTS_STEMMER` | supported LadybugDB stemmer | `porter` | Stemmer used when rebuilding BM25/FTS indexes. Use `none` for CJK-heavy repositories, or a language stemmer such as `german`, `french`, or `spanish` when that better matches repository comments and identifiers. Re-run `gitnexus analyze --repair-fts` after changing it. |
|
||||
| `GITNEXUS_FTS_CJK_SEGMENTATION` | `none`, `bigram` | `none` | `bigram` inserts overlapping character-bigram boundaries into Chinese/Japanese Han-ideograph spans in `content`/`description` before FTS indexing, so LadybugDB's space-only tokenizer can see sub-phrase word boundaries. Scoped to CJK Unified Ideographs only — Japanese Hiragana/Katakana and Korean Hangul are not currently segmented. Unlike `GITNEXUS_FTS_STEMMER`, this rewrites stored text — enabling it on an already-indexed repo requires a full `gitnexus analyze --force`; neither `--repair-fts` nor a plain incremental `analyze` applies it to previously-indexed files. Set the same value wherever `analyze` and search-serving processes (CLI query, MCP server, web server) run. |
|
||||
| `GITNEXUS_COMMUNITY_ENGINE` | `graphology`, `icebug`, `auto` | `graphology` | Community-detection engine used during analyze. `graphology` uses the bundled default path. `icebug` and `auto` currently behave identically: both try the experimental Icebug CSR path and fall back to Graphology if the optional native module is unavailable or incompatible. |
|
||||
| `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` | integer `>= -1` | `67108864` (64 MiB) | LadybugDB WAL auto-checkpoint threshold during analyze (bytes). Auto-checkpoint remains enabled; `-1` keeps Ladybug's stock ~16 MiB. Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. |
|
||||
| `GITNEXUS_LBUG_BUFFER_POOL_SIZE` | integer `>= 0` (bytes) | min(2 GiB, 80% RAM) | LadybugDB buffer-pool ceiling for every GitNexus database (analyze, MCP server, serve, group bridges). Bounded so a long-lived `gitnexus mcp` process or a large incremental `analyze` cannot grow toward LadybugDB's native 80%-of-RAM default and OOM the host (#2557). `0` restores that native unbounded default; invalid values warn and fall back to the default. |
|
||||
| `GITNEXUS_LBUG_MAX_DB_SIZE` | positive integer (bytes) | `17179869184` (16 GiB) | Upper bound for a single LadybugDB database file. This is an mmap/disk-address-space ceiling, not a memory limit — it does not constrain the buffer pool (use `GITNEXUS_LBUG_BUFFER_POOL_SIZE` for that). Raise it when indexing genuinely huge monorepos; invalid values silently fall back to the default. |
|
||||
| Variable | Values | Default | Effect |
|
||||
| -------------------------------------------- | ------------------------------ | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GITNEXUS_LBUG_EXTENSION_INSTALL` | `auto`, `load-only`, `never` | `auto` | `auto` runs one bounded install if LOAD fails — a plain `INSTALL`, escalating to `FORCE INSTALL` only when the LOAD error shows the present extension file is broken. `load-only` only uses already-installed extensions (recommended for offline / firewalled environments). `never` skips optional extensions entirely. |
|
||||
| `GITNEXUS_LBUG_EXTENSION_INSTALL_TIMEOUT_MS` | positive integer | `15000` | Wall-clock budget for the out-of-process extension-install child before it is killed. |
|
||||
| `GITNEXUS_FTS_STEMMER` | supported LadybugDB stemmer | `porter` | Stemmer used when rebuilding BM25/FTS indexes. Use `none` for CJK-heavy repositories, or a language stemmer such as `german`, `french`, or `spanish` when that better matches repository comments and identifiers. Re-run `gitnexus analyze --repair-fts` after changing it. |
|
||||
| `GITNEXUS_FTS_CJK_SEGMENTATION` | `none`, `bigram` | `none` | `bigram` inserts overlapping character-bigram boundaries into Chinese/Japanese Han-ideograph spans in `content`/`description` before FTS indexing, so LadybugDB's space-only tokenizer can see sub-phrase word boundaries. Scoped to CJK Unified Ideographs only — Japanese Hiragana/Katakana and Korean Hangul are not currently segmented. Unlike `GITNEXUS_FTS_STEMMER`, this rewrites stored text — enabling it on an already-indexed repo requires a full `gitnexus analyze --force`; neither `--repair-fts` nor a plain incremental `analyze` applies it to previously-indexed files. Set the same value wherever `analyze` and search-serving processes (CLI query, MCP server, web server) run. |
|
||||
| `GITNEXUS_STREAM_GRAPH_EMIT` | `0`, `1` | `1` (on) | **On by default** on a full rebuild (`--force`); incremental runs ignore it. Holds structural relationships (CALLS, IMPORTS, ACCESSES, CONTAINS, ...) as CSV-on-disk plus compact in-memory columns instead of as objects in three overlapping indexes, cutting peak in-memory graph heap by ~1.4x at no measurable CPU cost (measured A/B on a synthetic 400k-node / 1.08M-edge graph: 819 MB -> 584 MB, iteration at parity, scaling verified linear from 100k to 800k nodes, with every edge still visible through the graph interface; no end-to-end measurement on a real repository yet). Nothing is traded away — community detection, process extraction, PDG taint summaries and the local-symbol pruner all read a complete relationship set and behave identically. Set to `0` only to bisect a suspected streaming-related fault. |
|
||||
| `GITNEXUS_COMMUNITY_ENGINE` | `graphology`, `icebug`, `auto` | `graphology` | Community-detection engine used during analyze. `graphology` is the supported default. `icebug` and `auto` are **experimental** and currently behave identically: both try the optional `@ladybugmem/icebug` native Leiden over a CSR export and fall back to Graphology if it is not installed, cannot load, or lacks the deterministic thread/seed controls. Experimental engines partition differently, so community IDs are not comparable across engines. |
|
||||
| `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` | integer `>= -1` | `67108864` (64 MiB) | LadybugDB WAL auto-checkpoint threshold during analyze (bytes). Auto-checkpoint remains enabled; `-1` keeps Ladybug's stock ~16 MiB. Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. |
|
||||
| `GITNEXUS_LBUG_BUFFER_POOL_SIZE` | integer `>= 0` (bytes) | min(2 GiB, 80% RAM) | LadybugDB buffer-pool ceiling for every GitNexus database (analyze, MCP server, serve, group bridges). Bounded so a long-lived `gitnexus mcp` process or a large incremental `analyze` cannot grow toward LadybugDB's native 80%-of-RAM default and OOM the host (#2557). `0` restores that native unbounded default; invalid values warn and fall back to the default. During `analyze` the pool is right-sized to the graph and, on non-4 KiB-page hosts (Apple Silicon 16 KiB, Ascend/aarch64 64 KiB), scaled by the page-size granule ratio up to min(2 GiB × pageSize/4 KiB, 80% RAM) (#2631); this env var overrides all of that as an absolute value. |
|
||||
| `GITNEXUS_LBUG_MAX_DB_SIZE` | positive integer (bytes) | `17179869184` (16 GiB) | Upper bound for a single LadybugDB database file. This is an mmap/disk-address-space ceiling, not a memory limit — it does not constrain the buffer pool (use `GITNEXUS_LBUG_BUFFER_POOL_SIZE` for that). Raise it when indexing genuinely huge monorepos; invalid values silently fall back to the default. |
|
||||
|
||||
```bash
|
||||
# Offline/airgapped: never reach the network for extensions
|
||||
|
|
@ -570,6 +629,27 @@ GITNEXUS_FTS_CJK_SEGMENTATION=bigram npx gitnexus analyze --force
|
|||
|
||||
### Analysis runs out of memory
|
||||
|
||||
Memory management is automatic: `analyze` sizes its heap to the machine
|
||||
(always below physical RAM), caps each parse worker, and — rather than
|
||||
grinding into a GC death spiral or crash — stops early with a message telling
|
||||
you the one thing to do. Repeated
|
||||
`Replacement worker did not report ready within 5000ms` warnings on a large
|
||||
repository are part of the same picture: memory pressure starving healthy
|
||||
workers, not a worker bug (#2649).
|
||||
|
||||
If analyze says the repository doesn't fit, do what the message says:
|
||||
|
||||
- **The machine has more memory to give** (a `NODE_OPTIONS`
|
||||
`--max-old-space-size` pin from your environment is holding analyze back):
|
||||
re-run without the pin — no flags needed.
|
||||
- **The machine is the ceiling**: shrink the scope (exclude generated or
|
||||
vendored directories, below) or use a machine with more RAM.
|
||||
|
||||
Escape hatches (`GITNEXUS_MEMORY=off` to decline the autopilot,
|
||||
`GITNEXUS_WORKER_HEAP_MB` to size workers yourself) are listed in the
|
||||
environment-variable table below —
|
||||
most users never need them.
|
||||
|
||||
For very large repositories:
|
||||
|
||||
```bash
|
||||
|
|
@ -625,13 +705,16 @@ For repositories with very large source files, `GITNEXUS_WORKER_SUB_BATCH_MAX_BY
|
|||
|
||||
Four env vars expose the pool's resilience layers (respawn budget, cumulative-timeout cap, circuit breaker, startup handshake). Defaults are tuned for typical repos; bump them when an analyze legitimately needs more retries, or lower them to fail-fast on a known-bad shape.
|
||||
|
||||
| Variable | Default | Effect |
|
||||
| ----------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT` | `3` | Max replacement spawns per slot before the slot is dropped from the active rotation. |
|
||||
| `GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS` | `5 × subBatchTimeoutMs` | Total retry wall-time budget per job before quarantining. Bounds exponentially-growing retry waits. |
|
||||
| `GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD` | `max(3, poolSize)` | Per-slot consecutive deaths before the pool's circuit breaker trips. After tripping, dispatches require a fresh pool. |
|
||||
| `GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS` | `30000` | Max wait at pool shutdown for a retired worker still inside native code — terminated at its next JS-safe point instead of mid-native-call, which would abort the process (`Napi::Error`, #2432). |
|
||||
| Variable | Default | Effect |
|
||||
| ----------------------------------------------- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT` | `3` | Max replacement spawns per slot before the slot is dropped from the active rotation. |
|
||||
| `GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS` | `5 × subBatchTimeoutMs` | Total retry wall-time budget per job before quarantining. Bounds exponentially-growing retry waits. |
|
||||
| `GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD` | `max(3, poolSize)` | Per-slot consecutive deaths before the pool's circuit breaker trips. After tripping, dispatches require a fresh pool. |
|
||||
| `GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS` | `30000` | Max wait at pool shutdown for a retired worker still inside native code — terminated at its next JS-safe point instead of mid-native-call, which would abort the process (`Napi::Error`, #2432). |
|
||||
| `GITNEXUS_WORKER_READY_TIMEOUT_MS` | `5000` | Startup budget for a parse worker to load its grammar bindings and report `{type:'ready'}`. Slots that miss it are treated as startup crashes. Raise it on a slow or heavily loaded host where a full pool cold-starting concurrently needs more than 5s. |
|
||||
| `GITNEXUS_MEMORY` | `off` | unset (autopilot on) | `off` declines GitNexus's memory autopilot: analyze will neither re-run itself with a RAM-aware heap cap nor abort the parse before V8 enters its ineffective-mark-compact death spiral. Use it when you want to drive memory manually; to simply pin a heap size, pass Node's own `--max-old-space-size`, which is already honoured as your decision. |
|
||||
| `GITNEXUS_WORKER_HEAP_MB` | `clamp(512, RAM/2/poolSize, 4096)` | Per-worker V8 old-generation heap cap (#2649). Bounds pool RSS on large repos; a worker exceeding it dies with a real heap error handled by quarantine/respawn. |
|
||||
| `GITNEXUS_SERVER_ANALYZE_HEAP_MB` | `min(8192, auto cap)` | Heap for the web/MCP server's forked analyze worker (#2649). Defaults to the historical 8192 MB bounded by the machine/container's RAM-aware auto cap; set an absolute MB value to override. |
|
||||
| `GITNEXUS_CPP_CAPTURE_BUDGET_MS` | `20000` | Per-file wall-clock budget for C++ capture extraction; on breach the file keeps partial captures with a warning (#2432). `0` expires immediately. |
|
||||
|
||||
### Graph cleanup tuning
|
||||
|
|
|
|||
|
|
@ -39,20 +39,22 @@
|
|||
},
|
||||
"csharp": {
|
||||
"_rebaselined": "#1956 synth-widening: + csharp-qualified-base fixture; the synth now walks record_declaration + struct_declaration base_lists and handles alias_qualified_name (matching the #1940 legacy leg), so record/struct heritage now emits. csharp-record-base gains a record inherits capture. (record->record SAME-namespace EXTENDS is a separate registry resolution gap, tracked as follow-up.) Linear (~1.00). (Earlier #1956: heritage-bearing scale source.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged. | #1924 F16: record primary-constructor base bindings now exclude constructor arguments; capture fingerprint changes, scaling remains linear. | #2036 review follow-up: csharp-record-base now exercises primary-constructor base dispatch end to end; +2 capture groups, scaling remains linear.",
|
||||
"fingerprint": "75cf380209fa7d1a8a3ec873be1a9424b4e5173be0b08234c2291e8521a9b3c1",
|
||||
"fingerprint": "e05dc27456bde8175948586c9e7689033a378fa40e9ca4ce78cce41fbea0f2f8",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior f31544530924748f9aa37d11cec570bc10c3ddf9d9b237e6df7a17623fd2bb3a -> 75cf380209fa7d1a8a3ec873be1a9424b4e5173be0b08234c2291e8521a9b3c1; scaling 1.061 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: C# method-group/delegate callable flow facts with invocation-result suppression. Prior 2bb5bc8c19cb8eb08c9590545ad8a1968a7152951f7e12746e2d7901d542fed9 -> f31544530924748f9aa37d11cec570bc10c3ddf9d9b237e6df7a17623fd2bb3a; scaling 1.115 < 1.5.",
|
||||
"_note": "#2046: F35 qualified-constructor captures now emit @reference.qualified-name + a simple-name @reference.name on `new Ns.Foo()`/`new A.B.Foo()`; namespace_declaration/file_scoped_namespace_declaration now emit @declaration.namespace name captures (feeding the non-destructive namespacePrefix sidecar for `new B.Foo()` same-tail disambiguation). + csharp-interface-only-base and csharp-namespace-qualified-ctor fixtures. Pure capture-additive + fixture-corpus drift; scaling stays linear (~1.11)."
|
||||
"_note": "#2046: F35 qualified-constructor captures now emit @reference.qualified-name + a simple-name @reference.name on `new Ns.Foo()`/`new A.B.Foo()`; namespace_declaration/file_scoped_namespace_declaration now emit @declaration.namespace name captures (feeding the non-destructive namespacePrefix sidecar for `new B.Foo()` same-tail disambiguation). + csharp-interface-only-base and csharp-namespace-qualified-ctor fixtures. Pure capture-additive + fixture-corpus drift; scaling stays linear (~1.11).",
|
||||
"_rebaselined_2563_instance_ownership": "#2563: csharp-using-static adds same-file ownership, local-function, overload, partial-class, and cross-namespace same-name coverage. Prior 75cf380209fa7d1a8a3ec873be1a9424b4e5173be0b08234c2291e8521a9b3c1 -> e05dc27456bde8175948586c9e7689033a378fa40e9ca4ce78cce41fbea0f2f8; scaling 1.058 < 1.5."
|
||||
},
|
||||
"rust": {
|
||||
"fingerprint": "f7742f65f14d7d6590df7f16303fc3cc9dc0c233cd80bf90c98b084933cd3846",
|
||||
"fingerprint": "655aed01cf1b6b84fa0c64d48dfb2526ecb67f47d90f0a91edabacd269a212db",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_dyn_trait_object_2604": "#2604: RUST_SCOPE_QUERY now captures function_signature_item (abstract trait methods, no body) as a scope + declaration, so a &dyn Trait receiver can dispatch a CALLS edge to the trait's own method. Additive capture shift across every bench fixture with a required trait method. Prior df369c5a5f8de7753fc8bab8b4108ef5081750974ea5085ba9a867675ac9eb29 -> f7742f65f14d7d6590df7f16303fc3cc9dc0c233cd80bf90c98b084933cd3846; scaling 1.033 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 65e5bca66bb1ca117949409e8fb5c80ee69d6f1b5318908eaaecf08da0482e5c -> df369c5a5f8de7753fc8bab8b4108ef5081750974ea5085ba9a867675ac9eb29; scaling 1.065 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Rust fn-value callable flow facts with invocation/constructor-result suppression. Prior ac610bbe97666bf285923479dd7b43a2fe4c5354aae8df1bcbafdc04fb220f82 -> 65e5bca66bb1ca117949409e8fb5c80ee69d6f1b5318908eaaecf08da0482e5c; scaling 1.024 < 1.5.",
|
||||
"_rebaselined": "#1956 tri-review U1: rust-qualified-trait fixture (scoped + generic-of-scoped impl trait paths); bareTypeIdentifier now resolves scoped_type_identifier bases by their name: tail (additive, no existing-fixture drift); linear (~1.04). #1975: + rust-scoped-impl fixture (impl a::Inner / b::Inner inherent scoped impls) \u2014 legacy @definition.impl scoped arm + findEnclosingClassInfo inherent-impl scoped target; rust scope-extractor captures byte-identical. | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.",
|
||||
"_note": "PR #1934: F66/F68 let-binding pattern narrowing; F71 union (Struct-labeled, now materialized via legacy @definition.struct + resolvable); F72 macro FULLY WIRED \u2014 @declaration.macro/@reference.macro + MacroRegistry \u2192 USES edges to Macro nodes (never a same-named fn). + rust-macro / rust-union fixtures and merged with origin/main #1975 rust-scoped-impl; fingerprint re-baselined (scaling ~0.99, fixture_count 126). #1992: + rust-nested-tail-collision-generic and rust-generic-impl-same-method-name (F3) fixtures \u2014 pure fixture-corpus drift, no scope-extractor change; fixture_count 127->129, fingerprint 56ffc1c0->b00aea0f."
|
||||
"_note": "PR #1934: F66/F68 let-binding pattern narrowing; F71 union (Struct-labeled, now materialized via legacy @definition.struct + resolvable); F72 macro FULLY WIRED \u2014 @declaration.macro/@reference.macro + MacroRegistry \u2192 USES edges to Macro nodes (never a same-named fn). + rust-macro / rust-union fixtures and merged with origin/main #1975 rust-scoped-impl; fingerprint re-baselined (scaling ~0.99, fixture_count 126). #1992: + rust-nested-tail-collision-generic and rust-generic-impl-same-method-name (F3) fixtures \u2014 pure fixture-corpus drift, no scope-extractor change; fixture_count 127->129, fingerprint 56ffc1c0->b00aea0f.",
|
||||
"_rebaselined_import_disambiguation_2514": "#2514: added rust-import-* and rust-dup-* fixtures under lang-resolution for the range-binding ambiguity latch + import-disambiguated resolution (for-loops / struct destructuring across explicit/aliased/glob use imports). emitRustScopeCaptures is unchanged; the corpus fingerprint shifts purely because the fixture set grew (130 -> 174). Prior f7742f65f14d7d6590df7f16303fc3cc9dc0c233cd80bf90c98b084933cd3846 -> 655aed01cf1b6b84fa0c64d48dfb2526ecb67f47d90f0a91edabacd269a212db; scaling 1.06 < 1.5."
|
||||
},
|
||||
"php": {
|
||||
"fingerprint": "4a688fa5a7016546f7f3c6d44de023608ae80c5b0e3670c16f6e61b3632608fd",
|
||||
|
|
@ -90,7 +92,7 @@
|
|||
"_rebaselined": "#1919 review CF3 fix: extended kotlin-local-property-owner (init/accessor destructuring) + new dart-accessor-owner fixture (getter/setter ownership). Fingerprint-only corpus drift; scaling ~1.0."
|
||||
},
|
||||
"java": {
|
||||
"fingerprint": "d04298a91beec76d0fa7099b3d71265723be60c1df688969aa954f135dd49686",
|
||||
"fingerprint": "6dd5913a58400a191ff54abf9b852b03d5add657d16c11e60a7c4608ba186197",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata; same-name lexical regions use an O(ancestor-depth) ID-set lookup. Prior d5c59d7dc9e206637515d5aea1163f7c1cdd76410c38c5fe6143d13d19677d6a -> 004a3592998dca1193bd1429a8284513725de7764f2a3eceedaaa984cfd763b4; scaling 0.992 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Java method-reference/SAM callable flow facts with invocation-result suppression. Prior 062d754764aaa8a6772fb90875c710502a63e3e7a300e633942381ed914faada -> d5c59d7dc9e206637515d5aea1163f7c1cdd76410c38c5fe6143d13d19677d6a; scaling 1.074 < 1.5.",
|
||||
|
|
@ -100,7 +102,13 @@
|
|||
"_rebaselined_2550_instance_model": "PR #2549 (#2550): anonymous class bodies emit synthesized @declaration.class/@declaration.name (Worker$N), an @reference.inherits to the constructed type, and receiver @type-binding.* captures; six new java-* fixtures joined the corpus. Prior f3b4f4b6610e07c3ac90deb1c53d3572b6ad55a36e5d7134984876d30031ff67 -> d79c3b92acfc866094981499b977388ca14f90839bca0c040342ab1cec00aa90; scaling 1.058 < 1.5.",
|
||||
"_rebaselined_2555_enum_constant_bodies": "PR for #2555: enum constant bodies emit synthesized E$N classes + @reference.inherits to the host enum; anonymous naming follows JLS 13.1 immediately-enclosing-type chains INCLUDING anonymous enclosing types (NestHost$1$1, N$1$1); six new java-* fixtures joined the corpus. Prior d79c3b92acfc866094981499b977388ca14f90839bca0c040342ab1cec00aa90 -> 975b68aaac6d06094260fb0c67f9b1bc03692ba7220669d192aca9dccd5fc0ca; scaling 1.05 < 1.5.",
|
||||
"_rebaselined_2564_record_capture": "PR for #2564: JAVA_QUERIES gained a (record_declaration name: (identifier) @name) @definition.record capture, previously entirely missing (record_declaration had no structure-phase capture at all, unlike class/interface/enum) - a record's methods existed as ownerless Method nodes with no HAS_METHOD edge. Two new java-* fixtures (java-record-methods, java-new-expr-chain-call) joined the corpus. Prior 975b68aaac6d06094260fb0c67f9b1bc03692ba7220669d192aca9dccd5fc0ca -> 85fc7af9c3c1bceac76cb4f27214410b04967682a2eaa7e468e26efd1f4e2537; scaling 1.059 < 1.5.",
|
||||
"_rebaselined_2561_enum_constant_receiver": "PR for #2561: synthesizeJavaAnonymousClassDeclarations now emits a class-scope @type-binding.annotation/name/type per enum constant (constant simple name -> its E$N synthesized class when bodied, else the host enum) so E.CONST.method() resolves through the existing compound-receiver chain walk. Two drivers of the drift, both in the java-enum-constant-body fixture (this bench's corpus IS test/fixtures/lang-resolution): (1) one extra type-binding match per enum_constant from the capture change; (2) review follow-up added a body-less Plain.java enum + EnumConst.dispatchToConstant/dispatchInherited methods (bodied-override, inherited-via-MRO, and body-less dispatch call sites). The review's fail-safe hardening (bodied constant binds ONLY to E$N, never the host enum, when name synthesis fails on a malformed tree) is output-neutral on this well-formed corpus (verified: fingerprint identical with and without it). Prior 85fc7af9c3c1bceac76cb4f27214410b04967682a2eaa7e468e26efd1f4e2537 -> d04298a91beec76d0fa7099b3d71265723be60c1df688969aa954f135dd49686; scaling < 1.5."
|
||||
"_rebaselined_2561_enum_constant_receiver": "PR for #2561: synthesizeJavaAnonymousClassDeclarations now emits a class-scope @type-binding.annotation/name/type per enum constant (constant simple name -> its E$N synthesized class when bodied, else the host enum) so E.CONST.method() resolves through the existing compound-receiver chain walk. Two drivers of the drift, both in the java-enum-constant-body fixture (this bench's corpus IS test/fixtures/lang-resolution): (1) one extra type-binding match per enum_constant from the capture change; (2) review follow-up added a body-less Plain.java enum + EnumConst.dispatchToConstant/dispatchInherited methods (bodied-override, inherited-via-MRO, and body-less dispatch call sites). The review's fail-safe hardening (bodied constant binds ONLY to E$N, never the host enum, when name synthesis fails on a malformed tree) is output-neutral on this well-formed corpus (verified: fingerprint identical with and without it). Prior 85fc7af9c3c1bceac76cb4f27214410b04967682a2eaa7e468e26efd1f4e2537 -> d04298a91beec76d0fa7099b3d71265723be60c1df688969aa954f135dd49686; scaling < 1.5.",
|
||||
"_rebaselined_2562_local_classes": "#2562: Java block-local classes, enums, records, and interfaces use source-type-relative JLS 13.1 Host$NLocal identities with javac-compatible per-(host, simple-name) numbering; anonymous numbering remains separate. Lexical aliases begin at each declaration and end with its immediate block. Expanded java-local-class-naming fixtures cover declaration order, disjoint blocks, initializers, lambdas, local type kinds, and recursive local/member/anonymous host chains. Prior d04298a91beec76d0fa7099b3d71265723be60c1df688969aa954f135dd49686 -> 6dd5913a58400a191ff54abf9b852b03d5add657d16c11e60a7c4608ba186197; scaling 1.204 < 1.5."
|
||||
},
|
||||
"java-local-types": {
|
||||
"fingerprint": "a9ad88de21ca6747a923260dbdf677fb74a004abbf9d57781f745e3a9027530b",
|
||||
"scaling_budget": 1.5,
|
||||
"_added": "#2562 performance follow-up: co-scales same-host, same-name local classes and anonymous classes to gate JLS binary-name ordinal allocation. Precomputed per-sequence ordinals reduce the focused 100->800 workload from 176->6655ms to 141->752ms; normalized 250->800 scaling is 1.054."
|
||||
},
|
||||
"typescript": {
|
||||
"fingerprint": "3280b13d3f9378ab23eee31c2edc779b5a9ae1e7bb510c23a24855b44406d2f4",
|
||||
|
|
@ -125,7 +133,7 @@
|
|||
"_rebaselined_2550_instance_model": "PR #2549 (#2545/#2551): object literals emit @scope.object. Prior 479927409bbdd9852a36172c8260aa56df260e99129a7a9c20a0d1903dd5538b -> f1ccf42a36895c8e34dcb724286f247d469835f2dcbb23ad3347190adc7fde1c; scaling 1.096 < 1.5."
|
||||
},
|
||||
"kotlin": {
|
||||
"fingerprint": "a6fce0dff00e88d41d85023eaf3f35016b5217c7e5225f24a598e4c70bb63091",
|
||||
"fingerprint": "9f159f8810d342ef1c821f466efd6920dad9a190f06000056e6cd2815861b195",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior bddba25d5a88152bbbee8d70e82c944b5302accb4b625df782adb1d4f7a7ac12 -> e856951c2a779163d555dadc8e1bf59304a86caed78ac1f450d9caa2b50f63d1; scaling 1.090 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Kotlin callable-reference flow facts with invocation-result suppression. Prior 4900431791f2b9280009deb2b82659c26ead8aa6fb8731190a7c505dec5a9041 -> bddba25d5a88152bbbee8d70e82c944b5302accb4b625df782adb1d4f7a7ac12; scaling 0.880 < 1.5.",
|
||||
|
|
@ -133,6 +141,7 @@
|
|||
"_rebaselined": "#1919 review CF3 fix: extended kotlin-local-property-owner (init/accessor destructuring) + new dart-accessor-owner fixture (getter/setter ownership). Fingerprint-only corpus drift; scaling ~1.0.",
|
||||
"_rebaselined_2271": "PR #2271: re-vendored tree-sitter-kotlin 0.3.8 -> unreleased fwcd main c8ac3d26 for `fun interface` support + new kotlin-fun-interface fixture in the corpus. Drift is both corpus-additive (the fixture) and grammar-driven (the new grammar parses `fun interface` as a class_declaration, not an ERROR node). Baselined to the NEW grammar's fingerprint, so this --check passes only once the regenerated prebuilds land \u2014 until then CI loads the committed 0.3.8 binary and the bench is red, same as the kotlin fun-interface integration tests. scaling ~0.83 (linear).",
|
||||
"_rebaselined_2522_review_fixes": "PR #2522 review fixes: fieldless assignment nodes decomposed positionally. Prior e856951c2a779163d555dadc8e1bf59304a86caed78ac1f450d9caa2b50f63d1 -> 4b31f46cfb004ba769a96feeb06ae4ef109c77410f54e7aaab4a688df599b112; scaling ratio re-verified within budget.",
|
||||
"_rebaselined_2550_instance_model": "PR #2549 (#2545): anonymous object expressions (object_literal) emit @scope.class, and the kotlin-object-literal-scope fixture joined the corpus. Prior 4b31f46cfb004ba769a96feeb06ae4ef109c77410f54e7aaab4a688df599b112 -> a6fce0dff00e88d41d85023eaf3f35016b5217c7e5225f24a598e4c70bb63091; scaling 0.951 < 1.5."
|
||||
"_rebaselined_2550_instance_model": "PR #2549 (#2545): anonymous object expressions (object_literal) emit @scope.class, and the kotlin-object-literal-scope fixture joined the corpus. Prior 4b31f46cfb004ba769a96feeb06ae4ef109c77410f54e7aaab4a688df599b112 -> a6fce0dff00e88d41d85023eaf3f35016b5217c7e5225f24a598e4c70bb63091; scaling 0.951 < 1.5.",
|
||||
"_rebaselined_2563_instance_ownership": "#2563: kotlin-instance-ownership adds unrelated, inherited, outer-instance, and anonymous-object coverage. Prior a6fce0dff00e88d41d85023eaf3f35016b5217c7e5225f24a598e4c70bb63091 -> 9f159f8810d342ef1c821f466efd6920dad9a190f06000056e6cd2815861b195; scaling 1.257 < 1.5."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -264,6 +264,23 @@ const LANGS = [
|
|||
` public long getId() { return this.id; }\n` +
|
||||
` public void setName(String v) { this.name = v; }\n}\n\n`,
|
||||
},
|
||||
{
|
||||
name: 'java-local-types',
|
||||
emit: emitJavaScopeCaptures,
|
||||
fixturePrefix: 'java-local',
|
||||
exts: ['.java'],
|
||||
file: 'bench-local.java',
|
||||
header:
|
||||
'package generated;\n\nclass Base {}\n\ninterface Marker {}\n\nclass Bench {\n void run() {\n',
|
||||
// Co-scale both independent ordinal sequences under one host; construction
|
||||
// and dispatch keep lexical-alias captures hot. The old per-identity
|
||||
// host-candidate filter made this combined workload quadratic.
|
||||
unit: (n) =>
|
||||
` { class Local extends Base implements Marker { long value() { return ${n}L; } } ` +
|
||||
`new Local().value(); }\n` +
|
||||
` Marker marker${n} = new Marker() {};\n`,
|
||||
footer: ' }\n}\n',
|
||||
},
|
||||
{
|
||||
name: 'typescript',
|
||||
emit: emitTsScopeCaptures,
|
||||
|
|
@ -309,7 +326,7 @@ const LANGS = [
|
|||
function generate(lang, entityCount) {
|
||||
let src = lang.header;
|
||||
for (let i = 0; i < entityCount; i++) src += lang.unit(i);
|
||||
return src;
|
||||
return src + (lang.footer ?? '');
|
||||
}
|
||||
|
||||
// ---- timing ----
|
||||
|
|
|
|||
7
gitnexus/package-lock.json
generated
7
gitnexus/package-lock.json
generated
|
|
@ -3006,11 +3006,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.5.2",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
|
||||
"integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
|
||||
"version": "8.6.0",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.0.tgz",
|
||||
"integrity": "sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.4.3",
|
||||
"ip-address": "^10.2.0"
|
||||
},
|
||||
"engines": {
|
||||
|
|
|
|||
|
|
@ -36,6 +36,19 @@ const PLATFORM_LOGIC = [
|
|||
// must exercise the Windows backslash branch, so run it on the OS matrix (#2394).
|
||||
'test/unit/cli-entry.test.ts',
|
||||
'test/unit/platform-capabilities.test.ts',
|
||||
// Windows drive-letter case variance in the analyzer runner-identity path
|
||||
// fields (#2668): normalizeAnalyzerRootPath is a POSIX no-op, so the
|
||||
// "identity path fields are normalizer-stable" fixpoint guard only bites on
|
||||
// the windows-latest matrix — it must run there, not just in the Ubuntu
|
||||
// full-suite where it's trivially green. Deliberately the split-out
|
||||
// normalization file, NOT analyzer-identity.test.ts: the latter's fixture
|
||||
// tests compare identity fields against raw temp-dir paths and fail on macOS,
|
||||
// where /var/... realpaths to /private/var/....
|
||||
'test/unit/analyzer-identity-path-normalization.test.ts',
|
||||
// `isInside` containment guard vs Windows cross-drive paths: path.relative
|
||||
// returns the absolute target across drives, so the guard needs isAbsolute.
|
||||
// Fixture-free and pathApi-injectable, so it is portable to every runner.
|
||||
'test/unit/analyzer-identity-is-inside.test.ts',
|
||||
// getconf page-size probe: explicit process.platform gate (win32 short-circuit)
|
||||
// plus a live-probe test whose only real non-4K coverage is macos-arm64's
|
||||
// 16 KiB pages — the exact hardware class #1231 targets (#2424 review).
|
||||
|
|
@ -82,6 +95,13 @@ const PLATFORM_LOGIC = [
|
|||
// POSIX and Windows — the fail-closed path-claim semantics must hold on the
|
||||
// real windows-latest path implementation (#2419/#2420).
|
||||
'test/unit/server-api-repo-resolution.test.ts',
|
||||
// The index write-lock (#2658) selects its backend by process.platform — the
|
||||
// OS socket lock (Windows named pipe / Linux abstract socket) vs the file
|
||||
// fallback — and its socket-backend describe block is gated to linux/win32.
|
||||
// The Ubuntu suite only proves the Linux abstract-socket path, so run it here
|
||||
// to exercise the Windows named-pipe backend and the macOS file fallback on
|
||||
// their real platforms (#2658 review H3).
|
||||
'test/unit/index-lock.test.ts',
|
||||
];
|
||||
|
||||
// Native LadybugDB integration tests — exercise the @ladybugdb/core
|
||||
|
|
@ -153,6 +173,14 @@ const SPAWN_CLI = [
|
|||
'test/integration/antigravity-hook-e2e.test.ts',
|
||||
'test/unit/local-cli-subprocess.test.ts',
|
||||
'test/unit/runner-exec-tail.test.ts',
|
||||
// Real cross-process single-writer lock coordination (#2658): child processes
|
||||
// contend for the lock and race to reclaim a dead holder. Process spawning,
|
||||
// kernel socket auto-release (Win named pipe / Linux abstract socket), and the
|
||||
// FILE-backend rename-steal reclaim (macOS/BSD default) all vary across OSes —
|
||||
// the exact behaviors the Windows/macOS matrix must prove. macOS timing first
|
||||
// exposed a file-backend double-admit race here (#2658 review); the reclaim is
|
||||
// now judgment-verified so a live holder is never displaced.
|
||||
'test/integration/analyze-index-lock-concurrency.test.ts',
|
||||
];
|
||||
|
||||
// Worker threads tests — exercise real worker_threads which have
|
||||
|
|
|
|||
|
|
@ -362,13 +362,32 @@ async function upsertGitNexusSection(
|
|||
}
|
||||
|
||||
/**
|
||||
* Install GitNexus skills as direct children of .claude/skills/
|
||||
* Works natively with Claude Code, Cursor, and GitHub Copilot
|
||||
* Some agents read skills from a repo-local `.agents/skills/` directory and
|
||||
* prefer it over the global `~/.agents/skills/` install. When the repo contains
|
||||
* an `.agents/` directory, skills written to `.claude/skills/` are mirrored
|
||||
* there too so those agents serve the up-to-date copies.
|
||||
*/
|
||||
async function installSkills(repoPath: string): Promise<string[]> {
|
||||
export async function shouldMirrorSkillsToAgents(repoPath: string): Promise<boolean> {
|
||||
try {
|
||||
const stat = await fs.stat(path.join(repoPath, '.agents'));
|
||||
return stat.isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 }> {
|
||||
const skillsDir = path.join(repoPath, '.claude', 'skills');
|
||||
const legacySkillsDir = path.join(skillsDir, 'gitnexus');
|
||||
const installedSkills: string[] = [];
|
||||
const agentsMirror = await shouldMirrorSkillsToAgents(repoPath);
|
||||
|
||||
for (const skill of STANDARD_SKILL_CATALOG.filter(
|
||||
(entry) => entry.distributions.project && entry.distributions.npm,
|
||||
|
|
@ -402,6 +421,18 @@ Use GitNexus tools to accomplish this task.
|
|||
}
|
||||
|
||||
await fs.writeFile(skillPath, skillContent, 'utf-8');
|
||||
|
||||
// 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');
|
||||
} catch (err) {
|
||||
logger.warn({ err }, `Warning: Could not mirror skill ${skill.name} to .agents/skills:`);
|
||||
}
|
||||
}
|
||||
|
||||
installedSkills.push(skill.name);
|
||||
|
||||
// Previous releases installed these known standard skills one level too
|
||||
|
|
@ -418,7 +449,7 @@ Use GitNexus tools to accomplish this task.
|
|||
}
|
||||
}
|
||||
|
||||
return installedSkills;
|
||||
return { skills: installedSkills, agentsMirror };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -496,9 +527,14 @@ export async function generateAIContextFiles(
|
|||
|
||||
// Install standard skills directly under .claude/skills/ (unless --skip-skills)
|
||||
if (!options?.skipSkills) {
|
||||
const installedSkills = await installSkills(repoPath);
|
||||
const { skills: installedSkills, agentsMirror } = await installSkills(repoPath);
|
||||
if (installedSkills.length > 0) {
|
||||
createdFiles.push(`.claude/skills/gitnexus-*/ (${installedSkills.length} skills)`);
|
||||
if (agentsMirror) {
|
||||
createdFiles.push(
|
||||
`.agents/skills/gitnexus-*/ (${installedSkills.length} skills mirrored for .agents)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
createdFiles.push('.claude/skills/gitnexus-*/ (skipped via --skip-skills)');
|
||||
|
|
|
|||
|
|
@ -33,7 +33,14 @@ import {
|
|||
assertAnalysisFinalized,
|
||||
type AnalyzerRunnerIdentity,
|
||||
} from '../storage/repo-manager.js';
|
||||
import { getGitRoot, hasGitDir, getDefaultBranch } from '../storage/git.js';
|
||||
import {
|
||||
getGitRoot,
|
||||
hasGitDir,
|
||||
getDefaultBranch,
|
||||
selfCommitContextFiles,
|
||||
snapshotSelfCommitSafety,
|
||||
} from '../storage/git.js';
|
||||
import { IndexLockTimeoutError } from '../storage/index-lock.js';
|
||||
import {
|
||||
loadAnalyzeConfig,
|
||||
mergeAnalyzeOptions,
|
||||
|
|
@ -47,7 +54,8 @@ import { getMaxFileSizeBannerMessage } from '../core/ingestion/utils/max-file-si
|
|||
import { warnMissingOptionalGrammars, getOptionalGrammarExtensions } from './optional-grammars.js';
|
||||
import { glob } from 'glob';
|
||||
import fs from 'fs/promises';
|
||||
import { cliError } from './cli-message.js';
|
||||
import { cliError, cliWarn } from './cli-message.js';
|
||||
import { heapCapMbFor, memoryAutopilotDisabled } from '../core/ingestion/utils/effective-ram.js';
|
||||
import { EMBEDDING_DIMS_ERROR, normalizeEmbeddingDims } from './embedding-dims.js';
|
||||
import { formatElapsed } from './format-elapsed.js';
|
||||
import { isHfDownloadFailure } from '../core/embeddings/hf-env.js';
|
||||
|
|
@ -138,25 +146,21 @@ const installFatalHandlers = (): void => {
|
|||
});
|
||||
};
|
||||
|
||||
/** Historical floor for the re-exec heap cap — the auto-sizer never goes below
|
||||
* this, so small boxes / CI never regress. */
|
||||
const DEFAULT_HEAP_MB = 16384;
|
||||
|
||||
/**
|
||||
* RAM-aware re-exec heap cap (MB): `0.75 × effective RAM`, clamped to
|
||||
* `>= DEFAULT_HEAP_MB`. Kept BELOW physical RAM on purpose — a cap `>=` RAM makes
|
||||
* V8 collect lazily and inflate the heap into swap-thrash (observed analyzing the
|
||||
* Linux kernel at a 30GB cap on a 31GB box). `constrainedBytes` is the cgroup
|
||||
* limit or `null`; it is honored only as a real, smaller-than-physical cap, because
|
||||
* RAM-aware re-exec heap cap (MB) — the formula itself is single-sourced in
|
||||
* `core/ingestion/utils/effective-ram.ts` (`heapCapMbFor`), shared with the
|
||||
* server's analyze fork. `constrainedBytes` is the cgroup limit or `null`;
|
||||
* it is honored only as a real, smaller-than-physical cap, because
|
||||
* `process.constrainedMemory()` returns a huge sentinel when UNCONSTRAINED.
|
||||
* (Observed rationale: a cap ≥ RAM made V8 collect lazily and swap-thrash —
|
||||
* the #2649 worker-timeout cascade on 16 GB boxes.)
|
||||
*/
|
||||
export function computeHeapCapMb(totalBytes: number, constrainedBytes: number | null): number {
|
||||
const effectiveBytes =
|
||||
constrainedBytes !== null && constrainedBytes > 0 && constrainedBytes < totalBytes
|
||||
? constrainedBytes
|
||||
: totalBytes;
|
||||
const effectiveMb = Math.floor(effectiveBytes / (1024 * 1024));
|
||||
return Math.max(DEFAULT_HEAP_MB, Math.floor(0.75 * effectiveMb));
|
||||
return heapCapMbFor(effectiveBytes);
|
||||
}
|
||||
|
||||
function readConstrainedBytes(): number | null {
|
||||
|
|
@ -526,21 +530,69 @@ const forceHeapOOMForTestIfEnabled = (): void => {
|
|||
// `gitnexus/src/core/lbug/lbug-config.ts` in sync with this value.
|
||||
const RECOMMENDED_WAL_CHECKPOINT_THRESHOLD = 64 * 1024 * 1024;
|
||||
|
||||
/** Re-exec the process with the RAM-aware auto heap cap + larger semi-space/stack
|
||||
* if we're currently below that. A user-supplied NODE_OPTIONS heap wins (no re-exec). */
|
||||
async function ensureHeap(): Promise<boolean> {
|
||||
const nodeOpts = process.env.NODE_OPTIONS || '';
|
||||
if (nodeOpts.includes('--max-old-space-size')) return false;
|
||||
/**
|
||||
* Last `--max-old-space-size` value (MB) in a NODE_OPTIONS string, or `null`
|
||||
* when absent/unparseable. Last occurrence wins, matching V8's own
|
||||
* later-flag-wins semantics when NODE_OPTIONS repeats a flag.
|
||||
*/
|
||||
export function parseMaxOldSpaceMb(nodeOptions: string): number | null {
|
||||
// V8 accepts `-` and `_` interchangeably in flag names, and Node accepts a
|
||||
// space-separated value in NODE_OPTIONS — honor every spelling of the pin
|
||||
// instead of silently overriding it (#2649 review).
|
||||
const matches = [...nodeOptions.matchAll(/--max[-_]old[-_]space[-_]size(?:=|\s+)(\d+)/g)];
|
||||
if (matches.length === 0) return null;
|
||||
const mb = Number(matches[matches.length - 1][1]);
|
||||
return Number.isFinite(mb) && mb > 0 ? mb : null;
|
||||
}
|
||||
|
||||
const v8Heap = v8.getHeapStatistics().heap_size_limit;
|
||||
if (v8Heap >= HEAP_MB * 1024 * 1024 * 0.9) return false;
|
||||
/** Re-exec the process with the RAM-aware auto heap cap + larger semi-space/stack
|
||||
* if we're currently below that.
|
||||
*
|
||||
* Heap-source precedence (#2649):
|
||||
* - an explicit per-invocation `--max-old-space-size` (execArgv) always wins;
|
||||
* - `GITNEXUS_MEMORY=off` declines the memory autopilot entirely;
|
||||
* - an ambient NODE_OPTIONS heap >= the auto cap is honored as-is;
|
||||
* - an ambient NODE_OPTIONS heap BELOW the auto cap is treated as an
|
||||
* inherited environment default (devcontainers/CI export one for other
|
||||
* tooling), not a deliberate per-run choice: warn and respawn with the
|
||||
* auto cap. Pre-#2649 this returned early and large repos then OOM'd on
|
||||
* whatever heap the environment happened to specify. */
|
||||
async function ensureHeap(): Promise<boolean> {
|
||||
// Explicit opt-out disables auto-sizing ENTIRELY — both the ambient-pin
|
||||
// override and the default v8-limit respawn — and is honored SILENTLY:
|
||||
// the operator already made the call, and stderr-sensitive consumers
|
||||
// (test harnesses, scripts, supervisors that track a single PID) rely on
|
||||
// a quiet, single-process run.
|
||||
if (memoryAutopilotDisabled()) return false;
|
||||
const nodeOpts = process.env.NODE_OPTIONS || '';
|
||||
if (process.execArgv.some((a) => a.startsWith('--max-old-space-size'))) return false;
|
||||
|
||||
const ambientHeapMb = parseMaxOldSpaceMb(nodeOpts);
|
||||
if (ambientHeapMb !== null) {
|
||||
if (ambientHeapMb >= RESPAWN_HEAP_MB) return false;
|
||||
cliWarn(
|
||||
` NODE_OPTIONS pins the heap to ${ambientHeapMb}MB — below the ${RESPAWN_HEAP_MB}MB this machine's RAM supports.\n` +
|
||||
` Re-running analyze with the larger auto-sized cap (set GITNEXUS_MEMORY=off to keep the NODE_OPTIONS value).\n`,
|
||||
);
|
||||
} else {
|
||||
const v8Heap = v8.getHeapStatistics().heap_size_limit;
|
||||
if (v8Heap >= HEAP_MB * 1024 * 1024 * 0.9) return false;
|
||||
}
|
||||
|
||||
// --stack-size is a V8 flag not allowed in NODE_OPTIONS on Node 24+, so pass it
|
||||
// only as a direct CLI argument. --max-semi-space-size IS allowed in NODE_OPTIONS.
|
||||
const cliFlags = [HEAP_FLAG, SEMI_FLAG];
|
||||
if (!nodeOpts.includes('--stack-size')) cliFlags.push(STACK_FLAG);
|
||||
|
||||
const childArgs = [...cliFlags, ...process.argv.slice(1)];
|
||||
// Preserve the parent's node flags (execArgv) — dropping them breaks any
|
||||
// loader-launched CLI: `node --import tsx src/cli/index.ts` respawned
|
||||
// without `--import tsx` cannot execute TypeScript and dies with a
|
||||
// swallowed exit 1 (#2649 review). Our heap/semi/stack flags come AFTER
|
||||
// execArgv so V8's later-flag-wins semantics resolve duplicates our way.
|
||||
// Inspector flags are the one exception: replaying `--inspect[-brk]` makes
|
||||
// the child fight the parent for the debug port and die with EADDRINUSE.
|
||||
const preservedExecArgv = process.execArgv.filter((a) => !a.startsWith('--inspect'));
|
||||
const childArgs = [...preservedExecArgv, ...cliFlags, ...process.argv.slice(1)];
|
||||
const childEnv = {
|
||||
...process.env,
|
||||
NODE_OPTIONS: `${nodeOpts} ${HEAP_FLAG} ${SEMI_FLAG}`.trim(),
|
||||
|
|
@ -658,6 +710,13 @@ export interface AnalyzeOptions {
|
|||
* default-on case.
|
||||
*/
|
||||
stats?: boolean;
|
||||
/**
|
||||
* Opt-in auto-commit of any AGENTS.md/CLAUDE.md changes this `analyze` run
|
||||
* makes. Scoped to only those two files (never `git add -A`); no-ops
|
||||
* silently if neither exists, neither changed, or the commit step itself
|
||||
* fails (e.g. no git identity configured). See #2639.
|
||||
*/
|
||||
selfCommit?: boolean;
|
||||
/** Skip installing standard GitNexus skill files directly under .claude/skills/. */
|
||||
skipSkills?: boolean;
|
||||
/**
|
||||
|
|
@ -1404,6 +1463,15 @@ const analyzeCommandImpl = async (
|
|||
const bootstrapArgs: [] | [AnalyzerRunnerIdentity] = runnerIdentityAtBootstrap
|
||||
? [runnerIdentityAtBootstrap]
|
||||
: [];
|
||||
// #2639 review round 2: snapshot which of AGENTS.md/CLAUDE.md are safe to
|
||||
// auto-commit BEFORE runFullAnalysis (and the --skills regeneration
|
||||
// further down) writes to them, so selfCommitContextFiles can tell a
|
||||
// pre-existing unstaged user edit apart from this run's stats refresh
|
||||
// and refuse to sweep the former into the latter's commit.
|
||||
const selfCommitSafety =
|
||||
options.selfCommit === true
|
||||
? snapshotSelfCommitSafety(repoPath, ['AGENTS.md', 'CLAUDE.md'])
|
||||
: undefined;
|
||||
const result = await runFullAnalysis(repoPath, runOptions, runCallbacks, ...bootstrapArgs);
|
||||
|
||||
if (result.alreadyUpToDate) {
|
||||
|
|
@ -1448,6 +1516,11 @@ const analyzeCommandImpl = async (
|
|||
` Updated base_ref to "${resolvedDefaultBranch}" in ${baseRefRefreshed.join(', ')}\n`,
|
||||
);
|
||||
}
|
||||
// #2639: opt-in self-commit of any AGENTS.md/CLAUDE.md churn from this
|
||||
// fast path (e.g. a base_ref refresh above). Best-effort — never throws.
|
||||
if (options.selfCommit === true && selfCommitSafety) {
|
||||
selfCommitContextFiles(repoPath, ['AGENTS.md', 'CLAUDE.md'], selfCommitSafety);
|
||||
}
|
||||
// Safe to return without process.exit(0) — the early-return path in
|
||||
// runFullAnalysis never opens LadybugDB, so no native handles prevent exit.
|
||||
return;
|
||||
|
|
@ -1537,6 +1610,14 @@ const analyzeCommandImpl = async (
|
|||
}
|
||||
}
|
||||
|
||||
// #2639: opt-in self-commit of any AGENTS.md/CLAUDE.md churn written by
|
||||
// this run (the primary generateAIContextFiles call inside
|
||||
// runFullAnalysis, and/or the --skills regeneration above). Best-effort
|
||||
// — never throws, so a missing git identity etc. can't fail `analyze`.
|
||||
if (options.selfCommit === true && selfCommitSafety) {
|
||||
selfCommitContextFiles(repoPath, ['AGENTS.md', 'CLAUDE.md'], selfCommitSafety);
|
||||
}
|
||||
|
||||
const totalTime = ((Date.now() - t0) / 1000).toFixed(1);
|
||||
|
||||
clearInterval(elapsedTimer);
|
||||
|
|
@ -1563,11 +1644,21 @@ const analyzeCommandImpl = async (
|
|||
// progress-bar log() that fired mid-run has already scrolled away, so the
|
||||
// degraded-search state must also appear in the final summary (#1161).
|
||||
if (result.ftsSkipped) {
|
||||
console.log(
|
||||
`\n Warning: full-text/BM25 search is disabled — the LadybugDB FTS extension was unavailable.\n` +
|
||||
` Install it once with network access (GITNEXUS_LBUG_EXTENSION_INSTALL=auto) then rerun, or\n` +
|
||||
` run \`gitnexus analyze --repair-fts\` when connected. Run \`gitnexus doctor\` for details.`,
|
||||
);
|
||||
// #2658 review L2: a build/verify failure is NOT an extension-unavailable
|
||||
// problem — sending the user to install the extension is the wrong remedy.
|
||||
if (result.ftsSkipReason === 'build-failed') {
|
||||
console.log(
|
||||
`\n Warning: full-text/BM25 search is disabled — the search index build failed this run.\n` +
|
||||
` The FTS extension is available; rerun \`gitnexus analyze --repair-fts\`. If it persists,\n` +
|
||||
` check the disk for space or corruption. Run \`gitnexus doctor\` for details.`,
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`\n Warning: full-text/BM25 search is disabled — the LadybugDB FTS extension was unavailable.\n` +
|
||||
` Install it once with network access (GITNEXUS_LBUG_EXTENSION_INSTALL=auto) then rerun, or\n` +
|
||||
` run \`gitnexus analyze --repair-fts\` when connected. Run \`gitnexus doctor\` for details.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Standalone-ingest warnings (skipped/degraded Move packages) share the
|
||||
|
|
@ -1613,6 +1704,22 @@ const analyzeCommandImpl = async (
|
|||
return;
|
||||
}
|
||||
|
||||
// Another analyze held the index lock past the configured wait ceiling
|
||||
// (#2658, GITNEXUS_INDEX_LOCK_TIMEOUT_MS). The on-disk index is being
|
||||
// refreshed by the holder — this is a clean, expected condition, not a
|
||||
// crash, so render the message without a stack trace.
|
||||
if (err instanceof IndexLockTimeoutError) {
|
||||
cliError(
|
||||
` Another gitnexus analyze (pid ${err.holder.pid} on ${err.holder.hostname}) is ` +
|
||||
`already refreshing this index and did not finish within the wait window.\n` +
|
||||
` The on-disk index is being updated by that run. Retry later, or raise\n` +
|
||||
` GITNEXUS_INDEX_LOCK_TIMEOUT_MS to wait longer.\n`,
|
||||
{ recoveryHint: 'index-lock-timeout', holderPid: err.holder.pid },
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Finalize invariant failure (#1169) — keep the rich actionable
|
||||
// message intact and write through realStderrWrite so it can't be
|
||||
// erased by a leftover bar refresh on slow terminals.
|
||||
|
|
|
|||
|
|
@ -59,7 +59,8 @@ export type RecoveryHint =
|
|||
| 'npm-resolution'
|
||||
| 'module-not-found'
|
||||
| 'gitnexusrc-invalid'
|
||||
| 'default-branch-invalid';
|
||||
| 'default-branch-invalid'
|
||||
| 'index-lock-timeout';
|
||||
|
||||
/**
|
||||
* Common shape for the optional structured-field bag passed to
|
||||
|
|
|
|||
|
|
@ -14,10 +14,15 @@ import {
|
|||
import { cudaRedirectDoctorStatus } from '../core/embeddings/onnxruntime-node-resolver.js';
|
||||
import {
|
||||
checkLbugNative,
|
||||
type NativeCheckResult,
|
||||
probeFtsExtensionLoad,
|
||||
probeVectorExtensionLoad,
|
||||
} from '../core/lbug/native-check.js';
|
||||
import { getOsPageSize, isPageSizeAwareLadybug } from '../core/lbug/lbug-config.js';
|
||||
import {
|
||||
getEffectiveBufferPoolSize,
|
||||
getOsPageSize,
|
||||
isPageSizeAwareLadybug,
|
||||
} from '../core/lbug/lbug-config.js';
|
||||
import { diagnoseExtensionLoad } from '../core/lbug/extension-load-error.js';
|
||||
import { getExtensionInstallPolicy } from '../core/lbug/extension-loader.js';
|
||||
import { t } from './i18n/index.js';
|
||||
|
|
@ -150,6 +155,49 @@ export function pageSizeDoctorLines(
|
|||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* The hintless buffer-pool doctor line (#2631) — the pool the next Database
|
||||
* open in THIS process would get. Same plain-params testable-helper shape as
|
||||
* pageSizeDoctorLines above. `pool` is getEffectiveBufferPoolSize(): `0` is
|
||||
* the pass-through sentinel for LadybugDB's native 80%-of-RAM default, never
|
||||
* printed as "0 MiB". `envRaw` (the raw GITNEXUS_LBUG_BUFFER_POOL_SIZE value)
|
||||
* marks operator-supplied absolute values as "(env override)" — no scaling
|
||||
* suffix: the hintless default is deliberately unscaled (#2557), and an env
|
||||
* value is absolute, so a "×N" note would misdescribe both.
|
||||
*/
|
||||
export function poolSizeDoctorLine(pool: number, envRaw: string | undefined): string {
|
||||
const value = pool === 0 ? 'native 80% of RAM' : `${Math.round(pool / (1024 * 1024))} MiB`;
|
||||
const envNote = envRaw !== undefined && envRaw.trim().length > 0 ? ' (env override)' : '';
|
||||
return ` ${padDisplayEnd('pool size', 10)}${value}${envNote}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `native` status line. Literal label like the page-size and pool-size lines
|
||||
* above (no i18n key).
|
||||
*
|
||||
* A failed check is not automatically a MISSING binary, and saying so is the
|
||||
* same misdiagnosis #2672 fixed one layer down: on a host whose glibc is too
|
||||
* old, `lbugjs.node` is present and merely unloadable, so "missing" sent users
|
||||
* to reinstall a file that was already there — while the detail written to
|
||||
* stderr right below said the opposite. Render what the check actually found.
|
||||
*/
|
||||
export function nativeStatusLine(check: NativeCheckResult): string {
|
||||
return ` ${padDisplayEnd('native', 10)}${nativeStatusText(check)}`;
|
||||
}
|
||||
|
||||
function nativeStatusText(check: NativeCheckResult): string {
|
||||
if (check.ok) return '✓ lbugjs.node loaded';
|
||||
switch (check.kind) {
|
||||
case 'package_missing':
|
||||
return '✗ @ladybugdb/core not installed';
|
||||
case 'load_failed':
|
||||
return '✗ lbugjs.node present but failed to load';
|
||||
default:
|
||||
// 'binary_missing', and any future kind: the conservative claim.
|
||||
return '✗ lbugjs.node missing';
|
||||
}
|
||||
}
|
||||
|
||||
export const doctorCommand = async () => {
|
||||
const fingerprint = getRuntimeFingerprint();
|
||||
const capabilities = getRuntimeCapabilities();
|
||||
|
|
@ -168,11 +216,14 @@ export const doctorCommand = async () => {
|
|||
for (const line of pageSizeDoctorLines(getOsPageSize(), fingerprint.ladybugdb)) {
|
||||
console.log(line);
|
||||
}
|
||||
// Hintless buffer pool for the next DB open (#2631). Literal label like
|
||||
// the page size line above (no i18n key).
|
||||
console.log(
|
||||
poolSizeDoctorLine(getEffectiveBufferPoolSize(), process.env.GITNEXUS_LBUG_BUFFER_POOL_SIZE),
|
||||
);
|
||||
const nativeCheck = checkLbugNative();
|
||||
if (nativeCheck.ok) {
|
||||
console.log(` ${padDisplayEnd('native', 10)}✓ lbugjs.node loaded`);
|
||||
} else {
|
||||
console.log(` ${padDisplayEnd('native', 10)}✗ lbugjs.node missing`);
|
||||
console.log(nativeStatusLine(nativeCheck));
|
||||
if (!nativeCheck.ok) {
|
||||
process.stderr.write(`\n${nativeCheck.message?.replace(/^/gm, ' ')}\n\n`);
|
||||
}
|
||||
console.log(` ${label('doctor.labels.onnx', 10)}${fingerprint.onnxruntime ?? 'unknown'}`);
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ const OPTION_DESCRIPTION_KEYS = {
|
|||
'analyze|--skills': 'help.option.analyze.skills',
|
||||
'analyze|--skip-agents-md': 'help.option.analyze.skipAgentsMd',
|
||||
'analyze|--no-stats': 'help.option.analyze.noStats',
|
||||
'analyze|--self-commit': 'help.option.analyze.selfCommit',
|
||||
'analyze|--skip-skills': 'help.option.analyze.skipSkills',
|
||||
'analyze|--index-only': 'help.option.analyze.indexOnly',
|
||||
'analyze|--skip-git': 'help.option.skipGit',
|
||||
|
|
|
|||
|
|
@ -184,8 +184,10 @@ export const en = {
|
|||
'help.option.analyze.skipAgentsMd':
|
||||
'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md',
|
||||
'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.',
|
||||
'help.option.analyze.skipSkills':
|
||||
'Skip installing standard GitNexus skill files directly under .claude/skills/. Does not suppress community skills from --skills (those use .claude/skills/gitnexus-area-*). Use --index-only to skip all AI-context file injection.',
|
||||
'Skip installing standard GitNexus skill files directly under .claude/skills/ and .agents/skills/. Does not suppress community skills from --skills (those use .claude/skills/gitnexus-area-*). Use --index-only to skip all AI-context file injection.',
|
||||
'help.option.analyze.indexOnly':
|
||||
'Pure index mode: skip all file injection (AGENTS.md, CLAUDE.md, skills)',
|
||||
'help.option.skipGit':
|
||||
|
|
|
|||
|
|
@ -175,8 +175,10 @@ export const zhCN = {
|
|||
'根据检测到的社区生成仓库专属 skill 文件(同时设置 --index-only 时无效)。',
|
||||
'help.option.analyze.skipAgentsMd': '跳过更新 AGENTS.md 和 CLAUDE.md 中的 gitnexus 区块',
|
||||
'help.option.analyze.noStats': '从 AGENTS.md 和 CLAUDE.md 中省略易变的文件/符号计数',
|
||||
'help.option.analyze.selfCommit':
|
||||
'在 analyze 后自动提交 AGENTS.md/CLAUDE.md 的变更(默认关闭,需显式开启)。仅限这两个文件(绝不使用 `git add -A`);若两者均不存在、均未变更,或仓库未配置 git 身份,则不执行任何操作。',
|
||||
'help.option.analyze.skipSkills':
|
||||
'跳过直接安装在 .claude/skills/ 下的标准 GitNexus skill 文件。不抑制 --skills 生成的社区 skill(位于 .claude/skills/gitnexus-area-*)。使用 --index-only 可跳过所有 AI 上下文文件注入。',
|
||||
'跳过直接安装在 .claude/skills/ 和 .agents/skills/ 下的标准 GitNexus skill 文件。不抑制 --skills 生成的社区 skill(位于 .claude/skills/gitnexus-area-*)。使用 --index-only 可跳过所有 AI 上下文文件注入。',
|
||||
'help.option.analyze.indexOnly': '纯索引模式:跳过所有文件注入(AGENTS.md、CLAUDE.md、skills)',
|
||||
'help.option.skipGit': '将提供的路径/cwd 视为索引根目录,并跳过向上查找 git 根目录',
|
||||
'help.option.analyze.name':
|
||||
|
|
|
|||
|
|
@ -92,9 +92,15 @@ program
|
|||
'checked-out working tree. Distinct from --default-branch (cosmetic base_ref).',
|
||||
)
|
||||
.option('--no-stats', 'Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md')
|
||||
.option(
|
||||
'--self-commit',
|
||||
'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.',
|
||||
)
|
||||
.option(
|
||||
'--skip-skills',
|
||||
'Skip installing standard GitNexus skill files directly under .claude/skills/. ' +
|
||||
'Skip installing standard GitNexus skill files directly under .claude/skills/ and .agents/skills/. ' +
|
||||
'Does not suppress community skills from --skills (those use .claude/skills/gitnexus-area-*). ' +
|
||||
'Use --index-only to skip all AI-context file injection.',
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1031,6 +1031,23 @@ export const RENAMED_SKILL_DIRS: Readonly<Record<string, readonly string[]>> = {
|
|||
*/
|
||||
export const LEGACY_SKILL_DIR_NAMES: readonly string[] = Object.values(RENAMED_SKILL_DIRS).flat();
|
||||
|
||||
/** Legacy skill dirs found during this run, keyed `oldName|newName` — flushed
|
||||
* as one grouped notice per rename by {@link flushSkillRenameNotices}. */
|
||||
const pendingSkillRenameNotices = new Map<string, string[]>();
|
||||
|
||||
/** Print the collected rename leftovers (one line per rename, all target
|
||||
* paths grouped) and reset the collector. */
|
||||
export function flushSkillRenameNotices(): void {
|
||||
for (const [key, paths] of pendingSkillRenameNotices) {
|
||||
const [oldName, skillName] = key.split('|');
|
||||
console.log(
|
||||
` Note: skill "${oldName}" was renamed to "${skillName}". Left in place ` +
|
||||
`(delete manually if you have not customized them): ${paths.join(', ')}`,
|
||||
);
|
||||
}
|
||||
pendingSkillRenameNotices.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Install GitNexus skills to a target directory.
|
||||
* Each skill is installed as {targetDir}/gitnexus-{skillName}/SKILL.md
|
||||
|
|
@ -1102,14 +1119,16 @@ async function installSkillsTo(targetDir: string): Promise<string[]> {
|
|||
// A directory superseded by a shipped rename is warned about, never
|
||||
// deleted: the installer cannot prove it owns the contents (users
|
||||
// customize installed skills or hand-write their own under these
|
||||
// names), so an upgrade must not destroy data.
|
||||
// names), so an upgrade must not destroy data. Collected instead of
|
||||
// printed here so a multi-tool setup emits one grouped notice per
|
||||
// rename, not one line per target directory.
|
||||
for (const oldName of RENAMED_SKILL_DIRS[skillName] ?? []) {
|
||||
const legacyDir = path.join(targetDir, oldName);
|
||||
if (await dirExists(legacyDir)) {
|
||||
console.log(
|
||||
`[gitnexus] skill "${oldName}" was renamed to "${skillName}"; ` +
|
||||
`left ${legacyDir} in place — delete it manually if you have not customized it.`,
|
||||
);
|
||||
const key = `${oldName}|${skillName}`;
|
||||
const paths = pendingSkillRenameNotices.get(key) ?? [];
|
||||
paths.push(legacyDir);
|
||||
pendingSkillRenameNotices.set(key, paths);
|
||||
}
|
||||
}
|
||||
installed.push(skillName);
|
||||
|
|
@ -1267,6 +1286,8 @@ export const setupCommand = async (options?: { codingAgent?: string[] | string }
|
|||
}
|
||||
}
|
||||
|
||||
flushSkillRenameNotices();
|
||||
|
||||
console.log('');
|
||||
console.log(' Summary:');
|
||||
console.log(
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { PipelineResult } from '../types/pipeline.js';
|
|||
import { CommunityNode, CommunityMembership } from '../core/ingestion/community-processor.js';
|
||||
import { ProcessNode } from '../core/ingestion/process-processor.js';
|
||||
import { KnowledgeGraph } from '../core/graph/types.js';
|
||||
import { shouldMirrorSkillsToAgents } from './ai-context.js';
|
||||
|
||||
const GENERATED_SKILL_PREFIX = 'gitnexus-area-';
|
||||
const MAX_SKILL_NAME_LENGTH = 64;
|
||||
|
|
@ -74,6 +75,12 @@ export const generateSkillFiles = async (
|
|||
const { communityResult, processResult, graph } = pipelineResult;
|
||||
const outputDir = path.join(repoPath, '.claude', 'skills');
|
||||
const legacyOutputDir = path.join(outputDir, 'generated');
|
||||
// Some agents prioritize repo-local .agents/skills over the global
|
||||
// ~/.agents/skills install (see shouldMirrorSkillsToAgents). When .agents/
|
||||
// exists, mirror the generated community skills there too so those agents
|
||||
// serve the up-to-date copies.
|
||||
const agentsOutputDir = path.join(repoPath, '.agents', 'skills');
|
||||
let mirrorToAgents = await shouldMirrorSkillsToAgents(repoPath);
|
||||
|
||||
// Community skills used to live under an undiscoverable `generated/`
|
||||
// grouping directory. Clear that GitNexus-owned legacy output and
|
||||
|
|
@ -95,6 +102,24 @@ export const generateSkillFiles = async (
|
|||
/* legacy output may not exist */
|
||||
}
|
||||
|
||||
// Mirror cleanup: clear only stale GitNexus-generated community skills under
|
||||
// .agents/skills/ (reserved gitnexus-area-* namespace), preserving mirrored
|
||||
// standard skills and any user-authored skills. Never clear the whole root.
|
||||
if (mirrorToAgents) {
|
||||
try {
|
||||
const entries = await fs.readdir(agentsOutputDir, { withFileTypes: true });
|
||||
await Promise.all(
|
||||
entries
|
||||
.filter((entry) => entry.isDirectory() && entry.name.startsWith(GENERATED_SKILL_PREFIX))
|
||||
.map((entry) =>
|
||||
fs.rm(path.join(agentsOutputDir, entry.name), { recursive: true, force: true }),
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
/* mirror root may not exist yet */
|
||||
}
|
||||
}
|
||||
|
||||
if (!communityResult || !communityResult.memberships.length) {
|
||||
console.log('\n Skills: no communities detected, skipping skill generation');
|
||||
return { skills: [], outputPath: outputDir };
|
||||
|
|
@ -135,6 +160,20 @@ export const generateSkillFiles = async (
|
|||
// Step 4: Ensure the shared project-skill root exists. Never clear it: it
|
||||
// also contains user-authored and standard GitNexus skills.
|
||||
await fs.mkdir(outputDir, { recursive: true });
|
||||
// The .agents/ mirror is a side flow: keep it a weak dependency. If the
|
||||
// mirror root cannot be created (e.g. `.agents/skills` exists as a file),
|
||||
// warn and disable mirroring for this run instead of aborting canonical
|
||||
// community-skill generation. Canonical writes below stay unaffected.
|
||||
if (mirrorToAgents) {
|
||||
try {
|
||||
await fs.mkdir(agentsOutputDir, { recursive: true });
|
||||
} catch (err) {
|
||||
console.log(
|
||||
`Warning: Could not create mirror root ${agentsOutputDir} — .agents/skills mirroring disabled for this run: ${err}`,
|
||||
);
|
||||
mirrorToAgents = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: Generate skill files
|
||||
const skills: GeneratedSkillInfo[] = [];
|
||||
|
|
@ -185,6 +224,19 @@ export const generateSkillFiles = async (
|
|||
await fs.mkdir(skillDir, { recursive: true });
|
||||
await fs.writeFile(path.join(skillDir, 'SKILL.md'), content, 'utf-8');
|
||||
|
||||
// Mirror to .agents/skills/ for agents that read repo-local skills
|
||||
// (see mirrorToAgents above). Best-effort: a per-skill mirror failure
|
||||
// must not abort canonical community-skill generation.
|
||||
if (mirrorToAgents) {
|
||||
try {
|
||||
const agentsSkillDir = path.join(agentsOutputDir, skillName);
|
||||
await fs.mkdir(agentsSkillDir, { recursive: true });
|
||||
await fs.writeFile(path.join(agentsSkillDir, 'SKILL.md'), content, 'utf-8');
|
||||
} catch (err) {
|
||||
console.log(`Warning: Could not mirror skill ${skillName} to .agents/skills: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
const info: GeneratedSkillInfo = {
|
||||
name: skillName,
|
||||
label: community.label,
|
||||
|
|
@ -201,6 +253,11 @@ export const generateSkillFiles = async (
|
|||
console.log(
|
||||
`\n ${skills.length} skills generated \u2192 .claude/skills/${GENERATED_SKILL_PREFIX}*/`,
|
||||
);
|
||||
if (mirrorToAgents) {
|
||||
console.log(
|
||||
` ${skills.length} skills mirrored \u2192 .agents/skills/${GENERATED_SKILL_PREFIX}*/ (.agents)`,
|
||||
);
|
||||
}
|
||||
|
||||
return { skills, outputPath: outputDir };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -381,7 +381,14 @@ const LIBC_VARIANT = detectLibcVariant();
|
|||
|
||||
function resolveRuntimeVariant(): RuntimeVariant {
|
||||
return {
|
||||
executablePath: resolveExistingPath(process.execPath),
|
||||
// Normalized like build.rootPath (#2668): executablePath is a compared
|
||||
// identity field (only invokedArtifact is stripped in the comparison), and
|
||||
// process.execPath carries the same Windows drive-letter case ambiguity —
|
||||
// so leaving it un-normalized would reintroduce the false-stale via runtime.
|
||||
executablePath: normalizeAnalyzerRootPath(
|
||||
resolveExistingPath(process.execPath),
|
||||
process.platform,
|
||||
),
|
||||
nodeVersion: process.version,
|
||||
platform: process.platform,
|
||||
architecture: process.arch,
|
||||
|
|
@ -524,6 +531,36 @@ function resolveExistingPath(candidate: string): string {
|
|||
return realpathSync.native(path.resolve(candidate));
|
||||
}
|
||||
|
||||
/**
|
||||
* Case-stabilize a path's Windows drive letter so two processes that observed
|
||||
* the same directory under different drive-letter casing (`c:\…` vs `C:\…`)
|
||||
* produce byte-identical analyzer-identity path fields (#2668).
|
||||
*
|
||||
* `realpathSync.native` canonicalizes 8.3 short names and symlinks but does not
|
||||
* guarantee the drive-letter case it returns — it can preserve whatever casing
|
||||
* the caller's path carried, and `import.meta.url` casing depends on how each
|
||||
* entry process (CLI shim vs `npx`/npm wrapper vs server worker) was launched.
|
||||
* When `analyze` stamps `build.rootPath` under one casing and `status`
|
||||
* recomputes it under another, `analyzerRunnerIdentitiesEqual` deep-compares
|
||||
* unequal and `status` reports a freshly-analyzed, untouched repo as stale.
|
||||
* Uppercasing the drive letter (drive letters are case-insensitive; uppercase
|
||||
* is the conventional form) collapses that variance. POSIX paths are returned
|
||||
* unchanged. `platform` is explicit so the transform is unit-testable off
|
||||
* Windows.
|
||||
*
|
||||
* The optional `\\?\` extended-length prefix (which `realpathSync.native` can
|
||||
* emit for paths over MAX_PATH) is preserved and the drive letter after it is
|
||||
* still normalized; UNC paths (`\\server\share`, `\\?\UNC\...`) have no drive
|
||||
* letter and are left untouched.
|
||||
*/
|
||||
export function normalizeAnalyzerRootPath(p: string, platform: NodeJS.Platform): string {
|
||||
if (platform !== 'win32') return p;
|
||||
return p.replace(
|
||||
/^(\\\\\?\\)?([a-z]):/,
|
||||
(_match, prefix: string | undefined, drive: string) => `${prefix ?? ''}${drive.toUpperCase()}:`,
|
||||
);
|
||||
}
|
||||
|
||||
function isFile(candidate: string): boolean {
|
||||
try {
|
||||
return statSync(candidate).isFile();
|
||||
|
|
@ -553,11 +590,30 @@ function manifestLabel(manifest: PackageManifest): string {
|
|||
return `${name}@${version}`;
|
||||
}
|
||||
|
||||
function isInside(parent: string, candidate: string): boolean {
|
||||
const relative = path.relative(parent, candidate);
|
||||
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..');
|
||||
/**
|
||||
* Whether `candidate` is `parent` itself or lives beneath it.
|
||||
*
|
||||
* The absolute-result rejection is load-bearing on Windows: `path.relative`
|
||||
* cannot express a relative path between two different drives, so it returns the
|
||||
* absolute target instead — `path.win32.relative('C:\\parent', 'D:\\other')` is
|
||||
* `'D:\\other'`. That string does not start with `..`, so the `..` checks alone
|
||||
* would report an unrelated drive as *inside* the parent. This mirrors the
|
||||
* containment guards elsewhere in the repo (`server/api.ts`,
|
||||
* `server/git-clone.ts`, `group/extractors/fs-utils.ts`), which all pair the
|
||||
* `..` check with `path.isAbsolute`.
|
||||
*
|
||||
* `pathApi` is injectable so the win32 semantics are unit-testable from a POSIX
|
||||
* runner; production callers always use the platform-bound `path`.
|
||||
*/
|
||||
function isInside(parent: string, candidate: string, pathApi: typeof path = path): boolean {
|
||||
const relative = pathApi.relative(parent, candidate);
|
||||
if (pathApi.isAbsolute(relative)) return false;
|
||||
return relative === '' || (!relative.startsWith(`..${pathApi.sep}`) && relative !== '..');
|
||||
}
|
||||
|
||||
/** Test seam for {@link isInside} (see `_hashAnalyzerIdentityFramesForTests`). */
|
||||
export const _isInsideForTests = isInside;
|
||||
|
||||
function resolveBuildRoot(analyzerModulePath: string): {
|
||||
packageRoot: string;
|
||||
buildRoot: string;
|
||||
|
|
@ -570,9 +626,18 @@ function resolveBuildRoot(analyzerModulePath: string): {
|
|||
const packageRoot = path.dirname(cursor);
|
||||
const packageJson = path.join(packageRoot, 'package.json');
|
||||
if (lstatSync(packageJson).isFile()) {
|
||||
// Normalize the drive-letter case at this single upstream source so
|
||||
// every derived identity path field — build.rootPath, identityCacheKey,
|
||||
// and (via collectDependencyInputs) dependencyRuntime.manifestPath /
|
||||
// lockfilePath — inherits a case-stable root and analyze-stamp equals
|
||||
// status-recompute regardless of launch-path casing (#2668).
|
||||
// Migration: a Windows index stamped before this fix carries the old,
|
||||
// un-normalized casing, so the first post-upgrade `status` sees one
|
||||
// spurious "stale" flip — self-healing on the next `analyze`, which
|
||||
// re-stamps the normalized (idempotent) form.
|
||||
return {
|
||||
packageRoot,
|
||||
buildRoot: cursor,
|
||||
packageRoot: normalizeAnalyzerRootPath(packageRoot, process.platform),
|
||||
buildRoot: normalizeAnalyzerRootPath(cursor, process.platform),
|
||||
kind: base === 'src' ? 'source' : 'distribution',
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -162,6 +162,11 @@ export const createKnowledgeGraph = (): KnowledgeGraph => {
|
|||
forEachRelationship(fn: (rel: GraphRelationship) => void) {
|
||||
relationshipMap.forEach(fn);
|
||||
},
|
||||
forEachRelationshipFields(
|
||||
fn: (sourceId: string, targetId: string, type: RelationshipType, confidence: number) => void,
|
||||
) {
|
||||
relationshipMap.forEach((rel) => fn(rel.sourceId, rel.targetId, rel.type, rel.confidence));
|
||||
},
|
||||
getNode: (id: string) => nodeMap.get(id),
|
||||
|
||||
// O(1) count getters - avoid creating arrays just for length
|
||||
|
|
|
|||
|
|
@ -27,6 +27,19 @@ export interface KnowledgeGraph {
|
|||
iterRelationshipsByType: (type: RelationshipType) => IterableIterator<GraphRelationship>;
|
||||
forEachNode: (fn: (node: GraphNode) => void) => void;
|
||||
forEachRelationship: (fn: (rel: GraphRelationship) => void) => void;
|
||||
/**
|
||||
* Zero-allocation relationship scan: fields, not objects (#2680).
|
||||
*
|
||||
* The whole-graph scans (the local-symbol pruner, community detection,
|
||||
* process extraction) read only these four fields, and materializing a
|
||||
* `GraphRelationship` per edge just to read them dominates iteration cost once
|
||||
* relationships are held columnar — measured at ~90 ms per analyze on a
|
||||
* million-edge graph. Prefer this over `forEachRelationship` in any pass that
|
||||
* walks every edge and needs no other field.
|
||||
*/
|
||||
forEachRelationshipFields: (
|
||||
fn: (sourceId: string, targetId: string, type: RelationshipType, confidence: number) => void,
|
||||
) => void;
|
||||
getNode: (id: string) => GraphNode | undefined;
|
||||
nodeCount: number;
|
||||
relationshipCount: number;
|
||||
|
|
@ -34,5 +47,12 @@ export interface KnowledgeGraph {
|
|||
addRelationship: (relationship: GraphRelationship) => void;
|
||||
removeNode: (nodeId: string) => boolean;
|
||||
removeNodesByFile: (filePath: string) => number;
|
||||
/**
|
||||
* Removes the relationship with this id, returning whether it existed.
|
||||
*
|
||||
* Implementations that offload relationships out of memory cannot always tell
|
||||
* "absent" from "already written out" — `GraphEmitSink` deliberately throws
|
||||
* rather than answering `false` for an edge it can no longer recall (#2680).
|
||||
*/
|
||||
removeRelationship: (relationshipId: string) => boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type { ClassExtractionConfig } from '../../class-types.js';
|
||||
import { synthesizeJavaAnonymousClassName } from '../../utils/ast-helpers.js';
|
||||
import { synthesizeJavaTypeIdentity } from '../../utils/ast-helpers.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Java
|
||||
|
|
@ -33,10 +33,10 @@ export const javaClassConfig: ClassExtractionConfig = {
|
|||
'record_declaration',
|
||||
],
|
||||
extractName(node) {
|
||||
if (node.type === 'object_creation_expression' || node.type === 'enum_constant') {
|
||||
return synthesizeJavaAnonymousClassName(node);
|
||||
}
|
||||
return undefined;
|
||||
return synthesizeJavaTypeIdentity(node)?.name;
|
||||
},
|
||||
extractType(node) {
|
||||
return synthesizeJavaTypeIdentity(node)?.label;
|
||||
},
|
||||
// An anonymous body whose name CANNOT be synthesized must not become a
|
||||
// Class node at all. Without this skip, `extract()`'s
|
||||
|
|
@ -50,7 +50,7 @@ export const javaClassConfig: ClassExtractionConfig = {
|
|||
definitionNode !== undefined &&
|
||||
(definitionNode.type === 'object_creation_expression' ||
|
||||
definitionNode.type === 'enum_constant') &&
|
||||
synthesizeJavaAnonymousClassName(definitionNode) === undefined
|
||||
synthesizeJavaTypeIdentity(definitionNode) === undefined
|
||||
);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -47,9 +47,12 @@ export type CommunityDetectionEngine = CommunityEngine | 'auto';
|
|||
|
||||
export interface CommunityDetectionOptions {
|
||||
/**
|
||||
* Graphology remains the default. `icebug`/`auto` are guarded prototype
|
||||
* paths for #2337 and fall back to Graphology if the optional native module
|
||||
* is not available or does not expose the expected API.
|
||||
* Graphology is the supported default. `icebug`/`auto` are **experimental**:
|
||||
* they route through the optional `@ladybugmem/icebug` native Leiden (#2337)
|
||||
* and fall back to Graphology if it is not installed, cannot load, or
|
||||
* predates the thread/seed controls determinism requires. The two engines
|
||||
* partition differently, so switching changes community IDs — and with them
|
||||
* any generated context keyed on those IDs. No stability guarantee.
|
||||
*/
|
||||
engine?: CommunityDetectionEngine;
|
||||
icebug?: {
|
||||
|
|
@ -88,7 +91,8 @@ interface CommunityEngineResult extends LeidenDetailedResult {
|
|||
|
||||
interface IcebugWorkerSuccess {
|
||||
ok: true;
|
||||
partition: number[];
|
||||
/** `Leiden.getPartition().membership` — a Float64Array over the worker boundary. */
|
||||
partition: ArrayLike<number>;
|
||||
modularity: number;
|
||||
}
|
||||
|
||||
|
|
@ -116,6 +120,12 @@ function createSeededRng(seed: number): () => number {
|
|||
}
|
||||
|
||||
const COMMUNITY_ENGINE_ENV = 'GITNEXUS_COMMUNITY_ENGINE';
|
||||
/**
|
||||
* Not a declared dependency: the prebuilds need system Arrow 24, libomp and
|
||||
* glibc >= 2.38, so it stays an opt-in `npm i @ladybugmem/icebug` alongside
|
||||
* GitNexus rather than 30MB every install pays for.
|
||||
*/
|
||||
const ICEBUG_MODULE = '@ladybugmem/icebug';
|
||||
const DEFAULT_COMMUNITY_ENGINE: CommunityEngine = 'graphology';
|
||||
const LEIDEN_TIMEOUT_MS = 60_000;
|
||||
const ICEBUG_TIMEOUT_MS = 60_000;
|
||||
|
|
@ -290,14 +300,16 @@ export const buildCommunityProjection = (knowledgeGraph: KnowledgeGraph): Commun
|
|||
const connectedNodes = new Set<string>();
|
||||
const nodeDegree = new Map<string, number>();
|
||||
|
||||
knowledgeGraph.forEachRelationship((rel) => {
|
||||
if (!isClusteringRelationship(rel.type) || rel.sourceId === rel.targetId) return;
|
||||
if (isLarge && rel.confidence < MIN_CONFIDENCE_LARGE) return;
|
||||
// Field-wise scan (#2680): this walks every edge and reads only these four,
|
||||
// so taking objects would allocate one per edge for nothing.
|
||||
knowledgeGraph.forEachRelationshipFields((sourceId, targetId, type, confidence) => {
|
||||
if (!isClusteringRelationship(type) || sourceId === targetId) return;
|
||||
if (isLarge && confidence < MIN_CONFIDENCE_LARGE) return;
|
||||
|
||||
connectedNodes.add(rel.sourceId);
|
||||
connectedNodes.add(rel.targetId);
|
||||
nodeDegree.set(rel.sourceId, (nodeDegree.get(rel.sourceId) || 0) + 1);
|
||||
nodeDegree.set(rel.targetId, (nodeDegree.get(rel.targetId) || 0) + 1);
|
||||
connectedNodes.add(sourceId);
|
||||
connectedNodes.add(targetId);
|
||||
nodeDegree.set(sourceId, (nodeDegree.get(sourceId) || 0) + 1);
|
||||
nodeDegree.set(targetId, (nodeDegree.get(targetId) || 0) + 1);
|
||||
});
|
||||
|
||||
const nodes: CommunityProjectionNode[] = [];
|
||||
|
|
@ -328,12 +340,12 @@ export const buildCommunityProjection = (knowledgeGraph: KnowledgeGraph): Commun
|
|||
const seenEdges = new Set<string>();
|
||||
const edges: Array<readonly [number, number]> = [];
|
||||
|
||||
knowledgeGraph.forEachRelationship((rel) => {
|
||||
if (!isClusteringRelationship(rel.type) || rel.sourceId === rel.targetId) return;
|
||||
if (isLarge && rel.confidence < MIN_CONFIDENCE_LARGE) return;
|
||||
knowledgeGraph.forEachRelationshipFields((sourceId, targetId, type, confidence) => {
|
||||
if (!isClusteringRelationship(type) || sourceId === targetId) return;
|
||||
if (isLarge && confidence < MIN_CONFIDENCE_LARGE) return;
|
||||
|
||||
const sourceIndex = nodeIndexById.get(rel.sourceId);
|
||||
const targetIndex = nodeIndexById.get(rel.targetId);
|
||||
const sourceIndex = nodeIndexById.get(sourceId);
|
||||
const targetIndex = nodeIndexById.get(targetId);
|
||||
if (sourceIndex === undefined || targetIndex === undefined || sourceIndex === targetIndex)
|
||||
return;
|
||||
|
||||
|
|
@ -417,6 +429,15 @@ const runCommunityEngine = async (
|
|||
return runGraphologyLeiden(graph, projection.isLarge, engineRequested);
|
||||
}
|
||||
|
||||
// Announced on request, not just on fallback: a run that succeeds is the case
|
||||
// where the user most needs to know the partition came from the experimental
|
||||
// engine, since community IDs feed generated context.
|
||||
onProgress?.(
|
||||
`Experimental ${engineRequested} community engine requested — unsupported, and its ` +
|
||||
'communities will not match the Graphology default.',
|
||||
32,
|
||||
);
|
||||
|
||||
try {
|
||||
return await runIcebugLeiden(projection, engineRequested, options);
|
||||
} catch (error) {
|
||||
|
|
@ -481,10 +502,7 @@ const runIcebugLeiden = async (
|
|||
if (!Number.isFinite(nativeResult.modularity)) {
|
||||
throw new Error('optional icebug modularity was not finite');
|
||||
}
|
||||
if (
|
||||
partition.length !== projection.nodes.length ||
|
||||
partition.some((community) => !Number.isSafeInteger(community))
|
||||
) {
|
||||
if (partition.length !== projection.nodes.length || !isIntegerPartition(partition)) {
|
||||
throw new Error(
|
||||
`optional icebug partition was malformed for ${projection.nodes.length} projected nodes`,
|
||||
);
|
||||
|
|
@ -500,6 +518,13 @@ const runIcebugLeiden = async (
|
|||
};
|
||||
};
|
||||
|
||||
const isIntegerPartition = (partition: ArrayLike<number>): boolean => {
|
||||
for (let index = 0; index < partition.length; index++) {
|
||||
if (!Number.isSafeInteger(partition[index])) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const runIcebugWorker = (
|
||||
nodeCount: number,
|
||||
csr: CommunityCsr,
|
||||
|
|
@ -531,14 +556,21 @@ const runIcebugWorker = (
|
|||
let settled = false;
|
||||
const timeout = setTimeout(() => {
|
||||
settled = true;
|
||||
void worker.terminate();
|
||||
// Deliberately NOT terminate(): every millisecond of this worker's life is
|
||||
// spent inside an N-API call (dlopen, GraphR, Leiden, run), and killing a
|
||||
// thread mid-N-API aborts the whole process — Napi::Error → std::terminate
|
||||
// → SIGABRT (#2432, see worker-pool.ts `shutdownDrainMs`). A timeout must
|
||||
// degrade to the Graphology fallback, not take analyze down with it.
|
||||
// unref() so a wedged native run cannot hold the process open either.
|
||||
worker.unref();
|
||||
reject(new Error(`optional icebug community engine timed out after ${ICEBUG_TIMEOUT_MS}ms`));
|
||||
}, ICEBUG_TIMEOUT_MS);
|
||||
|
||||
// No terminate() on the settled paths either: the worker script ends after
|
||||
// its single postMessage, so the thread exits on its own.
|
||||
worker.once('message', (message: IcebugWorkerSuccess | IcebugWorkerFailure) => {
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
void worker.terminate();
|
||||
if (message.ok === true) {
|
||||
resolve(message);
|
||||
} else {
|
||||
|
|
@ -549,7 +581,6 @@ const runIcebugWorker = (
|
|||
worker.once('error', (error) => {
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
void worker.terminate();
|
||||
reject(error);
|
||||
});
|
||||
|
||||
|
|
@ -565,86 +596,61 @@ const runIcebugWorker = (
|
|||
});
|
||||
};
|
||||
|
||||
const ICEBUG_WORKER_SOURCE = `
|
||||
/**
|
||||
* Runs Leiden in a worker so a native crash cannot take the analyze process
|
||||
* with it. Written against @ladybugmem/icebug's published surface (lib/index.js
|
||||
* + index.d.ts): `GraphR(n, directed, outIndices, outIndptr)` pins the CSR
|
||||
* buffers zero-copy, and `Leiden(graph, iterations, randomize, gamma)` — note
|
||||
* `randomize` precedes `gamma` — returns `{membership, count}` from
|
||||
* `getPartition()`.
|
||||
*
|
||||
* The thread/seed controls are required, not optional: community IDs feed
|
||||
* generated context, so a build without them would give non-reproducible
|
||||
* output. They exist at icebug-nodejs HEAD but are missing from the published
|
||||
* 12.8.0 tarball, so today this guard is what trips and sends us back to
|
||||
* Graphology.
|
||||
*/
|
||||
export const buildIcebugWorkerSource = (moduleSpecifier: string): string => `
|
||||
const { parentPort, workerData } = require('node:worker_threads');
|
||||
|
||||
const isNumericArrayLike = (value) =>
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'length' in value &&
|
||||
typeof value.length === 'number';
|
||||
|
||||
const readPartition = (runner) => {
|
||||
const candidates = [
|
||||
typeof runner.getPartition === 'function' ? runner.getPartition() : runner.partition,
|
||||
typeof runner.getCommunities === 'function' ? runner.getCommunities() : undefined,
|
||||
typeof runner.getMembership === 'function' ? runner.getMembership() : undefined,
|
||||
typeof runner.getMemberships === 'function' ? runner.getMemberships() : undefined,
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (isNumericArrayLike(candidate)) {
|
||||
return Array.from(candidate, Number);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('optional icebug ParallelLeidenView did not expose a partition array');
|
||||
};
|
||||
|
||||
const readModularity = (runner) => {
|
||||
if (typeof runner.getModularity === 'function') return runner.getModularity();
|
||||
if (typeof runner.modularity === 'function') return runner.modularity();
|
||||
if (typeof runner.modularity === 'number') return runner.modularity;
|
||||
return 0;
|
||||
};
|
||||
|
||||
(async () => {
|
||||
const imported = await import('icebug');
|
||||
const icebug = imported.default ?? imported;
|
||||
const fromCSR = icebug.Graph?.fromCSR;
|
||||
const ParallelLeidenView = icebug.community?.ParallelLeidenView;
|
||||
if (!fromCSR || !ParallelLeidenView) {
|
||||
throw new Error('optional icebug module does not expose Graph.fromCSR/ParallelLeidenView');
|
||||
}
|
||||
try {
|
||||
const icebug = require(${JSON.stringify(moduleSpecifier)});
|
||||
|
||||
if (typeof icebug.setNumberOfThreads !== 'function' || typeof icebug.setSeed !== 'function') {
|
||||
throw new Error('optional icebug module does not expose deterministic thread/seed controls');
|
||||
}
|
||||
icebug.setNumberOfThreads(workerData.threads);
|
||||
icebug.setSeed(workerData.seed, false);
|
||||
|
||||
const nativeGraph = fromCSR(workerData.nodeCount, false, workerData.indices, workerData.indptr);
|
||||
let runner;
|
||||
try {
|
||||
runner = new ParallelLeidenView(nativeGraph, {
|
||||
iterations: workerData.iterations,
|
||||
gamma: workerData.gamma,
|
||||
randomize: workerData.randomize,
|
||||
});
|
||||
} catch {
|
||||
runner = new ParallelLeidenView(
|
||||
nativeGraph,
|
||||
workerData.iterations,
|
||||
workerData.gamma,
|
||||
workerData.randomize,
|
||||
throw new Error(
|
||||
'optional icebug build predates the deterministic thread/seed controls (icebug-nodejs#6)',
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof runner.run !== 'function') {
|
||||
throw new Error('optional icebug ParallelLeidenView does not expose run()');
|
||||
}
|
||||
icebug.setNumberOfThreads(workerData.threads);
|
||||
icebug.setSeed(workerData.seed, false);
|
||||
|
||||
const graph = new icebug.GraphR(
|
||||
workerData.nodeCount,
|
||||
false,
|
||||
workerData.indices,
|
||||
workerData.indptr,
|
||||
);
|
||||
const leiden = new icebug.Leiden(
|
||||
graph,
|
||||
workerData.iterations,
|
||||
workerData.randomize,
|
||||
workerData.gamma,
|
||||
);
|
||||
leiden.run();
|
||||
|
||||
runner.run();
|
||||
parentPort.postMessage({
|
||||
ok: true,
|
||||
partition: readPartition(runner),
|
||||
modularity: readModularity(runner),
|
||||
partition: leiden.getPartition().membership,
|
||||
modularity: leiden.modularity(),
|
||||
});
|
||||
})().catch((error) => {
|
||||
} catch (error) {
|
||||
parentPort.postMessage({ ok: false, error: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
}
|
||||
`;
|
||||
|
||||
const ICEBUG_WORKER_SOURCE = buildIcebugWorkerSource(ICEBUG_MODULE);
|
||||
|
||||
const normalizePartition = (
|
||||
projection: CommunityProjection,
|
||||
partition: ArrayLike<number>,
|
||||
|
|
|
|||
|
|
@ -1,61 +1,77 @@
|
|||
/**
|
||||
* Per-language DI field-matcher registry — the lookup the generic `di`
|
||||
* pipeline phase uses to decide whether a `Property` node is a
|
||||
* dependency-injection fan-out candidate.
|
||||
* Per-language DI resolver registry — the lookup the generic `di` pipeline
|
||||
* phase uses to discover injection sites and provider metadata on graph nodes.
|
||||
*
|
||||
* Mirrors `scope-resolution/pipeline/registry.ts` (`SCOPE_RESOLVERS`): a
|
||||
* single-valued `ReadonlyMap<SupportedLanguages, DiFieldMatcher>` consumed by
|
||||
* single-valued `ReadonlyMap<SupportedLanguages, DiResolver>` consumed by
|
||||
* a framework-neutral phase, so no language or framework names leak into
|
||||
* shared pipeline code. Adding a framework is two lines: implement a
|
||||
* `DiFieldMatcher` in `di-extractors/<framework>.ts` and register it here.
|
||||
* shared pipeline code. Adding a framework means implementing a `DiResolver`
|
||||
* in `di-extractors/<framework>.ts` and registering it here.
|
||||
*
|
||||
* Scope honesty: matchers are per-language *field-injection* matchers.
|
||||
* Constructor injection (the dominant modern Spring idiom) lives on
|
||||
* Method/parameter nodes and would require widening the phase's routing —
|
||||
* deliberately out of scope (see the plan's Deferred work). The registry is
|
||||
* single-valued per language, matching the `SCOPE_RESOLVERS` shape; widen the
|
||||
* value type to arrays only when a second same-language framework actually
|
||||
* lands (a one-line type change then).
|
||||
* The registry is single-valued per language, matching the `SCOPE_RESOLVERS`
|
||||
* shape; widen the value type to arrays only when a second same-language
|
||||
* framework actually lands. Java and Kotlin share Spring's attached metadata
|
||||
* contract while retaining language-specific syntax capture.
|
||||
*/
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type { GraphNode } from 'gitnexus-shared';
|
||||
import { springDiFieldMatcher } from './spring.js';
|
||||
import { springDiResolver } from './spring.js';
|
||||
|
||||
/** A successful DI field match, produced by a per-language matcher. */
|
||||
export interface DiFieldMatch {
|
||||
/** The element type name `T` — the injected bean interface. */
|
||||
elementTypeName: string;
|
||||
/** A successful injection-site match, produced by a per-language resolver. */
|
||||
export interface DiInjectionMatch {
|
||||
/** The requested dependency type name. */
|
||||
targetTypeName: string;
|
||||
/** A collection receives every matching provider; a single site may need
|
||||
* framework-specific named/preferred-provider disambiguation. */
|
||||
cardinality: 'single' | 'collection';
|
||||
/** Statically known provider name requested at the injection site. The
|
||||
* resolver owns the human-readable explanation of that selection. */
|
||||
namedSelection?: {
|
||||
name: string;
|
||||
reason: string;
|
||||
};
|
||||
/** Human-readable edge reason. Framework specifics (names, idioms,
|
||||
* collection wrapper, gating annotation) live in this payload so the
|
||||
* shared `di` phase stays framework-neutral. */
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A per-language field-injection matcher: given a `Property` node, return the
|
||||
* parsed DI match or `null` when the field is not container-injected. The
|
||||
* matcher receives the whole node (not pre-plucked fields) so the shared
|
||||
* phase stays ignorant of which properties matter.
|
||||
*/
|
||||
export type DiFieldMatcher = (node: GraphNode) => DiFieldMatch | null;
|
||||
/** Provider metadata used by the shared resolver without naming a framework. */
|
||||
export interface DiProviderMatch {
|
||||
/** Provider names and aliases that can satisfy a named injection. */
|
||||
names: readonly string[];
|
||||
/** Present when the framework marks this as its preferred candidate. The
|
||||
* value is appended to the emitted edge reason when it disambiguates. */
|
||||
preferenceReason?: string;
|
||||
}
|
||||
|
||||
/** Per-language DI behavior. Matchers receive whole nodes so the shared phase
|
||||
* remains ignorant of language/framework-specific property shapes. */
|
||||
export interface DiResolver {
|
||||
matchInjectionSites(node: GraphNode): readonly DiInjectionMatch[];
|
||||
matchProvider(node: GraphNode): DiProviderMatch | null;
|
||||
}
|
||||
|
||||
/** All `SupportedLanguages` string values, for narrowing raw graph strings. */
|
||||
const SUPPORTED_LANGUAGE_VALUES: ReadonlySet<string> = new Set(Object.values(SupportedLanguages));
|
||||
|
||||
/**
|
||||
* Type guard narrowing an arbitrary graph `language` string to
|
||||
* `SupportedLanguages`, so `DI_MATCHERS.get()` needs no cast.
|
||||
* `SupportedLanguages`, so `DI_RESOLVERS.get()` needs no cast.
|
||||
*/
|
||||
export function isSupportedLanguage(value: string): value is SupportedLanguages {
|
||||
return SUPPORTED_LANGUAGE_VALUES.has(value);
|
||||
}
|
||||
|
||||
/** Map of `SupportedLanguages` → `DiFieldMatcher`. The `di` phase routes each
|
||||
* `Property` node here by `node.properties.language`; no entry ⇒ the node is
|
||||
/** Map of `SupportedLanguages` → `DiResolver`. The `di` phase routes each
|
||||
* graph node here by `node.properties.language`; no entry ⇒ the node is
|
||||
* skipped. This is the single source of truth for which languages (and,
|
||||
* transitively, frameworks) produce INJECTS edges. */
|
||||
export const DI_MATCHERS: ReadonlyMap<SupportedLanguages, DiFieldMatcher> = new Map<
|
||||
export const DI_RESOLVERS: ReadonlyMap<SupportedLanguages, DiResolver> = new Map<
|
||||
SupportedLanguages,
|
||||
DiFieldMatcher
|
||||
>([[SupportedLanguages.Java, springDiFieldMatcher]]);
|
||||
DiResolver
|
||||
>([
|
||||
[SupportedLanguages.Java, springDiResolver],
|
||||
[SupportedLanguages.Kotlin, springDiResolver],
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -51,13 +51,15 @@
|
|||
* between `<` and the element) are NOT stripped and fail closed —
|
||||
* acceptable.
|
||||
*
|
||||
* Registered under `SupportedLanguages.Java` in `./index.ts` (`DI_MATCHERS`);
|
||||
* language routing is the registry's job, so the matcher itself never reads
|
||||
* `node.properties.language`.
|
||||
* Registered for Java and Kotlin in `./index.ts` (`DI_RESOLVERS`); language
|
||||
* routing is the registry's job, so the matcher itself never reads
|
||||
* `node.properties.language`. Kotlin's AST-backed class metadata is the
|
||||
* primary path because Kotlin Property extraction intentionally exposes less
|
||||
* annotation/type syntax than Java's legacy field contract.
|
||||
*/
|
||||
|
||||
import type { GraphNode } from 'gitnexus-shared';
|
||||
import type { DiFieldMatch, DiFieldMatcher } from './index.js';
|
||||
import type { DiInjectionMatch, DiProviderMatch, DiResolver } from './index.js';
|
||||
import { isDev } from '../utils/env.js';
|
||||
import { logger } from '../../logger.js';
|
||||
|
||||
|
|
@ -84,6 +86,17 @@ const WILDCARD_SUPER_PREFIX = '? super ';
|
|||
* punctuation) fails closed. */
|
||||
const JAVA_TYPE_NAME_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*$/;
|
||||
|
||||
/** Ephemeral Class-node property populated by Java's post-resolution Spring
|
||||
* metadata hook. It is consumed in the same pipeline run before persistence. */
|
||||
export const SPRING_DI_INJECTION_SITES_PROPERTY = 'springDiInjectionSites';
|
||||
|
||||
/** Ephemeral Class-node property carrying Spring bean names / @Primary. */
|
||||
export const SPRING_DI_PROVIDER_PROPERTY = 'springDiProvider';
|
||||
|
||||
/** Marker placed on Property nodes whose richer AST-backed field fact was
|
||||
* attached to the owning Class, suppressing the legacy collection fallback. */
|
||||
export const SPRING_DI_CAPTURED_FIELD_PROPERTY = 'springDiCapturedField';
|
||||
|
||||
/**
|
||||
* Split a generic-argument list on TOP-LEVEL commas only, tracking `<`/`>`
|
||||
* bracket depth so nested generics (e.g. the `Pair<A,B>` key in
|
||||
|
|
@ -181,13 +194,33 @@ export function parseSpringCollectionType(
|
|||
return { collectionType: wrapper, elementTypeName };
|
||||
}
|
||||
|
||||
/** Parse either a supported collect-all type or a standard single bean type. */
|
||||
export function parseSpringInjectionType(
|
||||
rawDeclaredType: string,
|
||||
): { targetTypeName: string; cardinality: 'single' | 'collection'; displayType: string } | null {
|
||||
const collection = parseSpringCollectionType(rawDeclaredType);
|
||||
if (collection !== null) {
|
||||
return {
|
||||
targetTypeName: collection.elementTypeName,
|
||||
cardinality: 'collection',
|
||||
displayType: `${collection.collectionType}<${collection.elementTypeName}>`,
|
||||
};
|
||||
}
|
||||
|
||||
const normalized = rawDeclaredType.replace(/\s+/g, '').trim();
|
||||
if (!JAVA_TYPE_NAME_PATTERN.test(normalized)) return null;
|
||||
return { targetTypeName: normalized, cardinality: 'single', displayType: normalized };
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a `Property` node against Spring's collection-injection shape.
|
||||
*
|
||||
* Returns the parsed match (with a Spring-specific human-readable `reason`
|
||||
* payload) or `null` when the field is not container-injected.
|
||||
*/
|
||||
export const springDiFieldMatcher: DiFieldMatcher = (node: GraphNode): DiFieldMatch | null => {
|
||||
export const springDiFieldMatcher = (
|
||||
node: GraphNode,
|
||||
): { elementTypeName: string; reason: string } | null => {
|
||||
// Injection-annotation gate: only fields the container actually
|
||||
// injects (@Autowired / @Inject) are candidates. Plain collection
|
||||
// fields are never injected; @Resource is deliberately excluded
|
||||
|
|
@ -220,3 +253,62 @@ export const springDiFieldMatcher: DiFieldMatcher = (node: GraphNode): DiFieldMa
|
|||
reason: `Spring DI: ${matchedAnnotation} ${parsed.collectionType}<${parsed.elementTypeName}>`,
|
||||
};
|
||||
};
|
||||
|
||||
function isInjectionMatch(value: unknown): value is DiInjectionMatch {
|
||||
if (value === null || typeof value !== 'object') return false;
|
||||
const match = value as Partial<DiInjectionMatch>;
|
||||
const namedSelection = match.namedSelection;
|
||||
return (
|
||||
typeof match.targetTypeName === 'string' &&
|
||||
(match.cardinality === 'single' || match.cardinality === 'collection') &&
|
||||
typeof match.reason === 'string' &&
|
||||
(namedSelection === undefined ||
|
||||
(typeof namedSelection === 'object' &&
|
||||
namedSelection !== null &&
|
||||
typeof namedSelection.name === 'string' &&
|
||||
typeof namedSelection.reason === 'string'))
|
||||
);
|
||||
}
|
||||
|
||||
function isProviderMatch(value: unknown): value is DiProviderMatch {
|
||||
if (value === null || typeof value !== 'object') return false;
|
||||
const provider = value as Partial<DiProviderMatch>;
|
||||
return (
|
||||
Array.isArray(provider.names) &&
|
||||
provider.names.every((name) => typeof name === 'string') &&
|
||||
(provider.preferenceReason === undefined || typeof provider.preferenceReason === 'string')
|
||||
);
|
||||
}
|
||||
|
||||
/** JVM/Spring resolver registered behind the framework-neutral DI seam. */
|
||||
export const springDiResolver: DiResolver = {
|
||||
matchInjectionSites(node): readonly DiInjectionMatch[] {
|
||||
const matches: DiInjectionMatch[] = [];
|
||||
|
||||
// Preserve the existing Property-node collection contract for hand-built
|
||||
// graphs and for compatibility with pre-#2414 extraction fixtures.
|
||||
if (node.label === 'Property' && node.properties[SPRING_DI_CAPTURED_FIELD_PROPERTY] !== true) {
|
||||
const field = springDiFieldMatcher(node);
|
||||
if (field !== null) {
|
||||
matches.push({
|
||||
targetTypeName: field.elementTypeName,
|
||||
cardinality: 'collection',
|
||||
reason: field.reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const attached = node.properties[SPRING_DI_INJECTION_SITES_PROPERTY];
|
||||
if (Array.isArray(attached)) {
|
||||
for (const candidate of attached) {
|
||||
if (isInjectionMatch(candidate)) matches.push(candidate);
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
},
|
||||
|
||||
matchProvider(node): DiProviderMatch | null {
|
||||
const attached = node.properties[SPRING_DI_PROVIDER_PROPERTY];
|
||||
return isProviderMatch(attached) ? attached : null;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import path from 'path';
|
|||
import { glob } from 'glob';
|
||||
import { createIgnoreFilter } from '../../config/ignore-service.js';
|
||||
|
||||
import { logger } from '../logger.js';
|
||||
import { warnRespectingProgressBar } from '../logger.js';
|
||||
|
||||
/** Lightweight entry — path + size from stat, no content in memory */
|
||||
export interface ScannedFile {
|
||||
|
|
@ -19,21 +19,8 @@ export interface FilePath {
|
|||
}
|
||||
|
||||
const READ_CONCURRENCY = 32;
|
||||
const ANALYZE_PROGRESS_ACTIVE_ENV = 'GITNEXUS_ANALYZE_PROGRESS_ACTIVE';
|
||||
|
||||
const warnLargeFileSkip = (message: string): void => {
|
||||
if (process.env[ANALYZE_PROGRESS_ACTIVE_ENV] === '1') {
|
||||
// analyze.ts routes console.warn through the progress bar logger while
|
||||
// the bar is active. Emitting the operator-facing large-file notice there
|
||||
// avoids raw pino NDJSON corrupting the one-line progress display in the
|
||||
// heap-respawn child, whose stderr is intentionally piped for crash
|
||||
// classification.
|
||||
// eslint-disable-next-line no-console -- intentionally routed by analyze progress UI
|
||||
console.warn(message);
|
||||
return;
|
||||
}
|
||||
logger.warn(message);
|
||||
};
|
||||
const warnLargeFileSkip = (message: string): void => warnRespectingProgressBar(message);
|
||||
|
||||
/**
|
||||
* Phase 1: Scan repository — stat files to get paths + sizes, no content loaded.
|
||||
|
|
|
|||
317
gitnexus/src/core/ingestion/frameworks/spring/di-metadata.ts
Normal file
317
gitnexus/src/core/ingestion/frameworks/spring/di-metadata.ts
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
import type { ParsedFile, ScopeId } from 'gitnexus-shared';
|
||||
import type { KnowledgeGraph } from '../../../graph/types.js';
|
||||
import type { DiInjectionMatch, DiProviderMatch } from '../../di-extractors/index.js';
|
||||
import {
|
||||
parseSpringInjectionType,
|
||||
SPRING_DI_CAPTURED_FIELD_PROPERTY,
|
||||
SPRING_DI_INJECTION_SITES_PROPERTY,
|
||||
SPRING_DI_PROVIDER_PROPERTY,
|
||||
} from '../../di-extractors/spring.js';
|
||||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import { resolveDefGraphId } from '../../scope-resolution/graph-bridge/ids.js';
|
||||
import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js';
|
||||
import { createSpringAnnotationNameResolver } from './bean-candidates.js';
|
||||
import { SPRING_BEAN_STEREOTYPES } from './bean-catalog.js';
|
||||
|
||||
export interface SpringDiAnnotationFact {
|
||||
readonly name: string;
|
||||
readonly text: string;
|
||||
}
|
||||
|
||||
export interface SpringDiDependencyFact<Annotation extends SpringDiAnnotationFact> {
|
||||
readonly name: string;
|
||||
readonly rawType: string;
|
||||
readonly annotations: readonly Annotation[];
|
||||
}
|
||||
|
||||
export interface SpringDiInjectionSiteFact<
|
||||
Annotation extends SpringDiAnnotationFact,
|
||||
SiteKind extends string,
|
||||
> {
|
||||
readonly kind: SiteKind;
|
||||
readonly memberName: string;
|
||||
readonly implicitConstructor: boolean;
|
||||
readonly annotations: readonly Annotation[];
|
||||
readonly dependencies: readonly SpringDiDependencyFact<Annotation>[];
|
||||
}
|
||||
|
||||
export interface SpringDiClassFact<
|
||||
Annotation extends SpringDiAnnotationFact,
|
||||
SiteKind extends string,
|
||||
> {
|
||||
readonly classScopeId: ScopeId;
|
||||
readonly classAnnotations: readonly Annotation[];
|
||||
readonly injectionSites: readonly SpringDiInjectionSiteFact<Annotation, SiteKind>[];
|
||||
}
|
||||
|
||||
const INJECTION_ANNOTATIONS = new Set([
|
||||
'org.springframework.beans.factory.annotation.Autowired',
|
||||
'jakarta.inject.Inject',
|
||||
'javax.inject.Inject',
|
||||
]);
|
||||
|
||||
const QUALIFIER_ANNOTATIONS = new Set([
|
||||
'org.springframework.beans.factory.annotation.Qualifier',
|
||||
'jakarta.inject.Named',
|
||||
'javax.inject.Named',
|
||||
]);
|
||||
|
||||
const PRIMARY_ANNOTATIONS = new Set(['org.springframework.context.annotation.Primary']);
|
||||
|
||||
const RESOLVABLE_DI_ANNOTATIONS = new Set([
|
||||
...SPRING_BEAN_STEREOTYPES.keys(),
|
||||
...INJECTION_ANNOTATIONS,
|
||||
...QUALIFIER_ANNOTATIONS,
|
||||
...PRIMARY_ANNOTATIONS,
|
||||
]);
|
||||
|
||||
const CAPTURE_RELEVANT_ANNOTATIONS = new Set([
|
||||
'Autowired',
|
||||
'Inject',
|
||||
'Qualifier',
|
||||
'Named',
|
||||
'Primary',
|
||||
'Component',
|
||||
'Service',
|
||||
'Repository',
|
||||
'Controller',
|
||||
'RestController',
|
||||
'Configuration',
|
||||
]);
|
||||
|
||||
const STEREOTYPE_SIMPLE_NAMES = new Set(
|
||||
[...SPRING_BEAN_STEREOTYPES.keys()].map((name) => springAnnotationSimpleName(name)),
|
||||
);
|
||||
|
||||
export function springAnnotationSimpleName(name: string): string {
|
||||
const separator = name.lastIndexOf('.');
|
||||
return separator === -1 ? name : name.slice(separator + 1);
|
||||
}
|
||||
|
||||
export function hasSpringDiRelevantAnnotation(
|
||||
annotations: readonly SpringDiAnnotationFact[],
|
||||
): boolean {
|
||||
return annotations.some((annotation) =>
|
||||
CAPTURE_RELEVANT_ANNOTATIONS.has(springAnnotationSimpleName(annotation.name)),
|
||||
);
|
||||
}
|
||||
|
||||
export function hasSpringStereotypeSyntax(annotations: readonly SpringDiAnnotationFact[]): boolean {
|
||||
return annotations.some((annotation) =>
|
||||
STEREOTYPE_SIMPLE_NAMES.has(springAnnotationSimpleName(annotation.name)),
|
||||
);
|
||||
}
|
||||
|
||||
function staticStringArgument(annotationText: string): string | undefined {
|
||||
const args = annotationText.match(/\((.*)\)$/s)?.[1]?.trim();
|
||||
if (args === undefined) return undefined;
|
||||
const value = args.replace(/^value\s*=\s*/, '').trim();
|
||||
const literal = value.match(/^"((?:\\.|[^"\\])*)"$/s);
|
||||
if (literal === null) return undefined;
|
||||
try {
|
||||
return JSON.parse(`"${literal[1]}"`) as string;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function defaultBeanName(className: string): string {
|
||||
if (className.length === 0) return className;
|
||||
if (
|
||||
className.length > 1 &&
|
||||
className[0] !== className[0].toLowerCase() &&
|
||||
className[1] !== className[1].toLowerCase()
|
||||
) {
|
||||
return className;
|
||||
}
|
||||
return className[0].toLowerCase() + className.slice(1);
|
||||
}
|
||||
|
||||
type ParsedSpringInjectionType = NonNullable<ReturnType<typeof parseSpringInjectionType>>;
|
||||
|
||||
export interface SpringDiMetadataAdapter<
|
||||
Annotation extends SpringDiAnnotationFact,
|
||||
SiteKind extends string,
|
||||
> {
|
||||
getFacts(filePath: string): readonly SpringDiClassFact<Annotation, SiteKind>[];
|
||||
isPackageVisibilityIncomplete(filePath: string): boolean;
|
||||
parseInjectionType(rawType: string): ParsedSpringInjectionType | null;
|
||||
capturedMemberKind: SiteKind;
|
||||
isInjectionAnnotationApplicable?(
|
||||
annotation: Annotation,
|
||||
site: SpringDiInjectionSiteFact<Annotation, SiteKind>,
|
||||
): boolean;
|
||||
isQualifierAnnotationApplicable?(
|
||||
annotation: Annotation,
|
||||
site: SpringDiInjectionSiteFact<Annotation, SiteKind>,
|
||||
): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the post-resolution Spring DI metadata hook shared by language adapters.
|
||||
* Language adapters retain syntax capture, type normalization, use-site rules,
|
||||
* and side-channel ownership; this function owns framework semantics only.
|
||||
*/
|
||||
export function createSpringDiMetadataAttacher<
|
||||
Annotation extends SpringDiAnnotationFact,
|
||||
SiteKind extends string,
|
||||
>(adapter: SpringDiMetadataAdapter<Annotation, SiteKind>) {
|
||||
return (
|
||||
graph: KnowledgeGraph,
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
nodeLookup: GraphNodeLookup,
|
||||
indexes: ScopeResolutionIndexes,
|
||||
): void => {
|
||||
const resolveAnnotation = createSpringAnnotationNameResolver(indexes);
|
||||
|
||||
for (const parsed of parsedFiles) {
|
||||
const incomplete = adapter.isPackageVisibilityIncomplete(parsed.filePath);
|
||||
for (const fact of adapter.getFacts(parsed.filePath)) {
|
||||
const classScope = indexes.scopeTree.getScope(fact.classScopeId);
|
||||
if (classScope === undefined || classScope.kind !== 'Class') continue;
|
||||
const classDef = classScope.ownedDefs.find((definition) => definition.type === 'Class');
|
||||
if (classDef === undefined) continue;
|
||||
const graphId = resolveDefGraphId(parsed.filePath, classDef, nodeLookup);
|
||||
if (graphId === undefined) continue;
|
||||
const classNode = graph.getNode(graphId);
|
||||
if (classNode === undefined || classNode.label !== 'Class') continue;
|
||||
|
||||
const resolvedAnnotations = new Map<string, string | undefined>();
|
||||
const resolveFact = (
|
||||
annotation: Annotation,
|
||||
enclosingScope: ScopeId | null = classScope.parent,
|
||||
): string | undefined => {
|
||||
const cacheKey = `${enclosingScope ?? '<root>'}\0${annotation.name}`;
|
||||
if (resolvedAnnotations.has(cacheKey)) return resolvedAnnotations.get(cacheKey);
|
||||
const resolved = resolveAnnotation(
|
||||
annotation.name,
|
||||
parsed,
|
||||
enclosingScope,
|
||||
RESOLVABLE_DI_ANNOTATIONS,
|
||||
incomplete,
|
||||
);
|
||||
resolvedAnnotations.set(cacheKey, resolved);
|
||||
return resolved;
|
||||
};
|
||||
|
||||
const frameworkAnnotations = Array.isArray(classNode.properties.frameworkAnnotations)
|
||||
? classNode.properties.frameworkAnnotations.filter(
|
||||
(annotation): annotation is string => typeof annotation === 'string',
|
||||
)
|
||||
: [];
|
||||
if (frameworkAnnotations.length > 0) {
|
||||
const names = new Set<string>();
|
||||
let explicitBeanName: string | undefined;
|
||||
let hasDynamicBeanName = false;
|
||||
let primary = false;
|
||||
for (const annotation of fact.classAnnotations) {
|
||||
const resolved = resolveFact(annotation);
|
||||
if (resolved === undefined) continue;
|
||||
if (SPRING_BEAN_STEREOTYPES.has(resolved)) {
|
||||
const argumentText = annotation.text.match(/\((.*)\)$/s)?.[1]?.trim();
|
||||
if (argumentText !== undefined && argumentText.length > 0) {
|
||||
const staticName = staticStringArgument(annotation.text);
|
||||
if (staticName === undefined) hasDynamicBeanName = true;
|
||||
else if (staticName.length > 0) explicitBeanName = staticName;
|
||||
}
|
||||
}
|
||||
if (QUALIFIER_ANNOTATIONS.has(resolved)) {
|
||||
const qualifier = staticStringArgument(annotation.text);
|
||||
if (qualifier !== undefined) names.add(qualifier);
|
||||
}
|
||||
if (PRIMARY_ANNOTATIONS.has(resolved)) primary = true;
|
||||
}
|
||||
if (explicitBeanName !== undefined) names.add(explicitBeanName);
|
||||
else if (!hasDynamicBeanName) names.add(defaultBeanName(classNode.properties.name));
|
||||
const provider: DiProviderMatch = {
|
||||
names: [...names],
|
||||
...(primary ? { preferenceReason: 'selected @Primary' } : {}),
|
||||
};
|
||||
classNode.properties[SPRING_DI_PROVIDER_PROPERTY] = provider;
|
||||
}
|
||||
|
||||
const matches: DiInjectionMatch[] = [];
|
||||
const semanticallyOwnedMemberNames = new Set<string>();
|
||||
for (const site of fact.injectionSites) {
|
||||
let injectionAnnotation: Annotation | undefined;
|
||||
for (const annotation of site.annotations) {
|
||||
if (adapter.isInjectionAnnotationApplicable?.(annotation, site) === false) continue;
|
||||
const resolved = resolveFact(annotation, classScope.id);
|
||||
if (resolved !== undefined && INJECTION_ANNOTATIONS.has(resolved)) {
|
||||
injectionAnnotation = annotation;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (injectionAnnotation === undefined) {
|
||||
if (!site.implicitConstructor || frameworkAnnotations.length === 0) continue;
|
||||
} else if (site.kind === adapter.capturedMemberKind) {
|
||||
// Claim the member only after its injection annotation resolves to
|
||||
// a recognized FQN. Ambiguous wildcard imports stay unclaimed so
|
||||
// the legacy collection matcher can fall back. A dynamic qualifier
|
||||
// later fails closed, but this path still owns the member and must
|
||||
// suppress that legacy fallback.
|
||||
semanticallyOwnedMemberNames.add(site.memberName);
|
||||
}
|
||||
|
||||
for (const dependency of site.dependencies) {
|
||||
const parsedType = adapter.parseInjectionType(dependency.rawType);
|
||||
if (parsedType === null) continue;
|
||||
let qualifierAnnotation: Annotation | undefined;
|
||||
for (const annotation of dependency.annotations) {
|
||||
if (adapter.isQualifierAnnotationApplicable?.(annotation, site) === false) continue;
|
||||
const resolved = resolveFact(annotation, classScope.id);
|
||||
if (resolved !== undefined && QUALIFIER_ANNOTATIONS.has(resolved)) {
|
||||
qualifierAnnotation = annotation;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const qualifier =
|
||||
qualifierAnnotation === undefined
|
||||
? undefined
|
||||
: staticStringArgument(qualifierAnnotation.text);
|
||||
// A present-but-dynamic qualifier is not the same as no qualifier.
|
||||
// Without its value we cannot choose a provider honestly, so fail
|
||||
// closed instead of emitting the unqualified candidate set.
|
||||
if (qualifierAnnotation !== undefined && qualifier === undefined) continue;
|
||||
const trigger =
|
||||
injectionAnnotation === undefined
|
||||
? 'constructor'
|
||||
: `@${springAnnotationSimpleName(injectionAnnotation.name)} ${site.kind}`;
|
||||
const location =
|
||||
site.kind === adapter.capturedMemberKind
|
||||
? site.memberName
|
||||
: `${site.memberName} parameter ${dependency.name}`;
|
||||
matches.push({
|
||||
targetTypeName: parsedType.targetTypeName,
|
||||
cardinality: parsedType.cardinality,
|
||||
...(qualifier === undefined
|
||||
? {}
|
||||
: {
|
||||
namedSelection: {
|
||||
name: qualifier,
|
||||
reason: `qualifier "${qualifier}"`,
|
||||
},
|
||||
}),
|
||||
reason: `Spring DI: ${trigger} ${location}: ${parsedType.displayType}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (matches.length > 0) {
|
||||
classNode.properties[SPRING_DI_INJECTION_SITES_PROPERTY] = matches;
|
||||
}
|
||||
|
||||
for (const memberName of semanticallyOwnedMemberNames) {
|
||||
for (const { def } of classScope.bindings.get(memberName) ?? []) {
|
||||
if (def.ownerId !== classDef.nodeId) continue;
|
||||
const propertyId = resolveDefGraphId(parsed.filePath, def, nodeLookup);
|
||||
if (propertyId === undefined) continue;
|
||||
const property = graph.getNode(propertyId);
|
||||
if (property?.label === 'Property') {
|
||||
property.properties[SPRING_DI_CAPTURED_FIELD_PROPERTY] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -98,6 +98,16 @@ const CPP_SCOPE_QUERY = `
|
|||
declarator: (function_declarator
|
||||
declarator: (identifier) @declaration.name)) @declaration.function
|
||||
|
||||
;; Lambda bindings (\`auto f = [](int x){ … };\`). The \`@declaration.function\`
|
||||
;; anchor sits on the INNER lambda_expression so its range aligns with
|
||||
;; \`(lambda_expression) @scope.function\` above; otherwise the def is owned by
|
||||
;; the enclosing scope and calls inside the lambda lose caller attribution.
|
||||
;; Mirrors the TypeScript arrow patterns (#2687).
|
||||
(declaration
|
||||
declarator: (init_declarator
|
||||
declarator: (identifier) @declaration.name
|
||||
value: (lambda_expression) @declaration.function))
|
||||
|
||||
;; ─── Declarations — function definition with pointer return ─────────
|
||||
(function_definition
|
||||
declarator: (pointer_declarator
|
||||
|
|
|
|||
|
|
@ -615,7 +615,11 @@ export function populateCsharpNamespaceSiblings(
|
|||
}
|
||||
if (seen.has(memberDef.nodeId)) continue;
|
||||
seen.add(memberDef.nodeId);
|
||||
bucketArr.push({ def: memberDef, origin: 'import' });
|
||||
bucketArr.push({
|
||||
def: memberDef,
|
||||
origin: 'import',
|
||||
visibility: 'static-member-import',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ const csharpScopeResolver: ScopeResolver = {
|
|||
// `(caller, target)` — multiple `g.Greet(...)` sites from Main
|
||||
// yield ONE edge, not one per site.
|
||||
collapseMemberCallsByCallerTarget: true,
|
||||
freeCallsRequireInstanceOwnership: true,
|
||||
|
||||
// C# hoists method return-type bindings to the enclosing Module
|
||||
// scope so `propagateImportedReturnTypes` can mirror them across
|
||||
|
|
|
|||
|
|
@ -35,6 +35,25 @@ const GO_SCOPE_QUERY = `
|
|||
(function_declaration
|
||||
name: (identifier) @declaration.name) @declaration.function
|
||||
|
||||
;; Declarations — closure bindings (\`var f = func(){}\`, \`f := func(){}\`).
|
||||
;; The \`@declaration.function\` anchor sits on the INNER func_literal so its
|
||||
;; range aligns with the \`(func_literal) @scope.function\` scope above —
|
||||
;; without that alignment pass2AttachDeclarations owns the def by the module
|
||||
;; scope and calls inside the closure lose caller attribution. Mirrors the
|
||||
;; TypeScript \`const f = () => {}\` patterns (#2687).
|
||||
(var_declaration
|
||||
(var_spec
|
||||
name: (identifier) @declaration.name
|
||||
value: (expression_list (func_literal) @declaration.function)))
|
||||
(var_declaration
|
||||
(var_spec_list
|
||||
(var_spec
|
||||
name: (identifier) @declaration.name
|
||||
value: (expression_list (func_literal) @declaration.function))))
|
||||
(short_var_declaration
|
||||
left: (expression_list (identifier) @declaration.name)
|
||||
right: (expression_list (func_literal) @declaration.function))
|
||||
|
||||
;; Declarations — method
|
||||
(method_declaration
|
||||
name: (field_identifier) @declaration.name) @declaration.method
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
} from '../jvm/package-facts.js';
|
||||
import { getJavaPackageFact, setJavaPackageFact } from './package-facts.js';
|
||||
import type { JavaSpringConfigConsumerFact } from './spring-config-bindings.js';
|
||||
import type { JavaSpringDiClassFact } from './spring-di.js';
|
||||
|
||||
export type JavaClassAnnotationFact = ClassAnnotationFact;
|
||||
|
||||
|
|
@ -18,15 +19,18 @@ export interface JavaCaptureSideChannel {
|
|||
readonly packageFact: JvmPackageFact;
|
||||
readonly classAnnotations: readonly JavaClassAnnotationFact[];
|
||||
readonly springConfigConsumers?: readonly JavaSpringConfigConsumerFact[];
|
||||
readonly springDiFacts?: readonly JavaSpringDiClassFact[];
|
||||
}
|
||||
|
||||
const classAnnotations = createClassAnnotationFactStore();
|
||||
const springConfigConsumers = new Map<string, readonly JavaSpringConfigConsumerFact[]>();
|
||||
const springDiFacts = new Map<string, readonly JavaSpringDiClassFact[]>();
|
||||
|
||||
/** Clear facts retained by a prior workspace pass in a long-lived process. */
|
||||
export function clearJavaClassAnnotationFacts(): void {
|
||||
classAnnotations.clear();
|
||||
springConfigConsumers.clear();
|
||||
springDiFacts.clear();
|
||||
}
|
||||
|
||||
/** Store the annotation syntax collected by Java's existing scope-query traversal. */
|
||||
|
|
@ -51,14 +55,32 @@ export function getJavaSpringConfigConsumerFacts(
|
|||
return springConfigConsumers.get(filePath) ?? [];
|
||||
}
|
||||
|
||||
export function setJavaSpringDiFacts(
|
||||
filePath: string,
|
||||
facts: readonly JavaSpringDiClassFact[],
|
||||
): void {
|
||||
if (facts.length === 0) springDiFacts.delete(filePath);
|
||||
else springDiFacts.set(filePath, facts);
|
||||
}
|
||||
|
||||
export function getJavaSpringDiFacts(filePath: string): readonly JavaSpringDiClassFact[] {
|
||||
return springDiFacts.get(filePath) ?? [];
|
||||
}
|
||||
|
||||
/** Snapshot worker-local Java annotation facts for ParsedFile serialization. */
|
||||
export function collectJavaCaptureSideChannel(
|
||||
filePath: string,
|
||||
): JavaCaptureSideChannel | undefined {
|
||||
const facts = classAnnotations.get(filePath);
|
||||
const configConsumers = springConfigConsumers.get(filePath) ?? [];
|
||||
const diFacts = springDiFacts.get(filePath) ?? [];
|
||||
const packageFact = getJavaPackageFact(filePath);
|
||||
if (facts.length === 0 && configConsumers.length === 0 && packageFact === undefined) {
|
||||
if (
|
||||
facts.length === 0 &&
|
||||
configConsumers.length === 0 &&
|
||||
diFacts.length === 0 &&
|
||||
packageFact === undefined
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
|
|
@ -66,6 +88,7 @@ export function collectJavaCaptureSideChannel(
|
|||
packageFact: packageFact ?? UNKNOWN_JVM_PACKAGE_FACT,
|
||||
classAnnotations: facts,
|
||||
...(configConsumers.length > 0 ? { springConfigConsumers: configConsumers } : {}),
|
||||
...(diFacts.length > 0 ? { springDiFacts: diFacts } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -85,6 +108,7 @@ export function applyJavaCaptureSideChannel(parsed: ParsedFile): void {
|
|||
) {
|
||||
setJavaClassAnnotationFacts(parsed.filePath, []);
|
||||
setJavaSpringConfigConsumerFacts(parsed.filePath, []);
|
||||
setJavaSpringDiFacts(parsed.filePath, []);
|
||||
setJavaPackageFact(parsed.filePath, UNKNOWN_JVM_PACKAGE_FACT);
|
||||
return;
|
||||
}
|
||||
|
|
@ -93,6 +117,10 @@ export function applyJavaCaptureSideChannel(parsed: ParsedFile): void {
|
|||
parsed.filePath,
|
||||
Array.isArray(data.springConfigConsumers) ? data.springConfigConsumers : [],
|
||||
);
|
||||
setJavaSpringDiFacts(
|
||||
parsed.filePath,
|
||||
Array.isArray(data.springDiFacts) ? data.springDiFacts : [],
|
||||
);
|
||||
setJavaPackageFact(
|
||||
parsed.filePath,
|
||||
isJvmPackageFact(data.packageFact) ? data.packageFact : UNKNOWN_JVM_PACKAGE_FACT,
|
||||
|
|
|
|||
|
|
@ -20,9 +20,10 @@ import {
|
|||
recordClassAnnotationCapture,
|
||||
} from '../../frameworks/spring/bean-candidates.js';
|
||||
import {
|
||||
javaLocalTypeDeclarationContainer,
|
||||
nodeIfType,
|
||||
nodeToCapture,
|
||||
synthesizeJavaAnonymousClassName,
|
||||
synthesizeJavaTypeIdentity,
|
||||
syntheticCapture,
|
||||
} from '../../utils/ast-helpers.js';
|
||||
import { splitImportDeclaration } from './import-decomposer.js';
|
||||
|
|
@ -35,16 +36,22 @@ import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
|
|||
import {
|
||||
setJavaClassAnnotationFacts,
|
||||
setJavaSpringConfigConsumerFacts,
|
||||
setJavaSpringDiFacts,
|
||||
} from './capture-side-channel.js';
|
||||
import { captureJavaPackageFact } from './package-facts.js';
|
||||
import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js';
|
||||
import { captureJavaSpringConfigConsumerFacts } from './spring-config-bindings.js';
|
||||
import { captureJavaSpringDiClassFact, type JavaSpringDiClassFact } from './spring-di.js';
|
||||
|
||||
/** Declaration anchors that carry function-like arity metadata. */
|
||||
const FUNCTION_DECL_TAGS = ['@declaration.method', '@declaration.constructor'] as const;
|
||||
|
||||
/** tree-sitter-java node types that the method extractor accepts. */
|
||||
const FUNCTION_NODE_TYPES = ['method_declaration', 'constructor_declaration'] as const;
|
||||
const FUNCTION_NODE_TYPES = [
|
||||
'method_declaration',
|
||||
'constructor_declaration',
|
||||
'compact_constructor_declaration',
|
||||
] as const;
|
||||
|
||||
const JAVA_CALLABLE_CAPTURE_OPTIONS = {
|
||||
functionNodeTypes: new Set([...FUNCTION_NODE_TYPES, 'lambda_expression']),
|
||||
|
|
@ -65,6 +72,26 @@ const JAVA_CALLABLE_CAPTURE_OPTIONS = {
|
|||
normalizeQualifiedName: (raw: string) => raw.replaceAll('::', '.'),
|
||||
} as const;
|
||||
|
||||
/** Visibility of a local type begins at its declaration and ends with its
|
||||
* immediately enclosing block (JLS 6.3). A Java-only synthetic Block scope
|
||||
* models that range without changing shared resolver selection semantics. */
|
||||
function javaLocalTypeVisibilityScope(node: SyntaxNode): CaptureMatch | undefined {
|
||||
const container = javaLocalTypeDeclarationContainer(node);
|
||||
if (container === null) return undefined;
|
||||
return {
|
||||
'@scope.block': {
|
||||
name: '@scope.block',
|
||||
range: {
|
||||
startLine: node.startPosition.row + 1,
|
||||
startCol: node.startPosition.column,
|
||||
endLine: container.endPosition.row + 1,
|
||||
endCol: container.endPosition.column,
|
||||
},
|
||||
text: node.text,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Suppress read.member emissions when the field_access is already
|
||||
* covered by a method_invocation (object of a call) or an
|
||||
* assignment_expression (write target). */
|
||||
|
|
@ -99,6 +126,8 @@ export function emitJavaScopeCaptures(
|
|||
const rawMatches = getJavaScopeQuery().matches(tree.rootNode);
|
||||
const out: CaptureMatch[] = [];
|
||||
const classAnnotations = new Map<ScopeId, Set<string>>();
|
||||
const springDiFacts: JavaSpringDiClassFact[] = [];
|
||||
const springDiClassNodeIds = new Set<number>();
|
||||
|
||||
for (const m of rawMatches) {
|
||||
const grouped: Record<string, Capture> = {};
|
||||
|
|
@ -118,6 +147,13 @@ export function emitJavaScopeCaptures(
|
|||
}
|
||||
if (Object.keys(grouped).length === 0) continue;
|
||||
|
||||
const springDiClassNode = nodeIfType(nodeMap['@scope.class'], 'class_declaration');
|
||||
if (springDiClassNode !== null && !springDiClassNodeIds.has(springDiClassNode.id)) {
|
||||
springDiClassNodeIds.add(springDiClassNode.id);
|
||||
const fact = captureJavaSpringDiClassFact(springDiClassNode, filePath);
|
||||
if (fact !== null) springDiFacts.push(fact);
|
||||
}
|
||||
|
||||
const annotatedClass = grouped['@class-annotation.class'];
|
||||
const annotationName = grouped['@class-annotation.name'];
|
||||
if (annotatedClass !== undefined && annotationName !== undefined) {
|
||||
|
|
@ -125,6 +161,29 @@ export function emitJavaScopeCaptures(
|
|||
continue;
|
||||
}
|
||||
|
||||
const typeDeclaration = [
|
||||
nodeMap['@declaration.class'],
|
||||
nodeMap['@declaration.enum'],
|
||||
nodeMap['@declaration.record'],
|
||||
nodeMap['@declaration.interface'],
|
||||
].find((node): node is SyntaxNode => node !== undefined);
|
||||
const localTypeIdentity =
|
||||
typeDeclaration === undefined ? undefined : synthesizeJavaTypeIdentity(typeDeclaration);
|
||||
if (
|
||||
localTypeIdentity?.bindingName !== undefined &&
|
||||
grouped['@declaration.name'] !== undefined &&
|
||||
typeDeclaration !== undefined
|
||||
) {
|
||||
grouped['@declaration.binding-name'] = grouped['@declaration.name'];
|
||||
grouped['@declaration.name'] = syntheticCapture(
|
||||
'@declaration.name',
|
||||
typeDeclaration,
|
||||
localTypeIdentity.name,
|
||||
);
|
||||
const visibilityScope = javaLocalTypeVisibilityScope(typeDeclaration);
|
||||
if (visibilityScope !== undefined) out.push(visibilityScope);
|
||||
}
|
||||
|
||||
// Decompose each `import_declaration`. `@import.statement` is captured
|
||||
// directly on the `import_declaration` node.
|
||||
if (grouped['@import.statement'] !== undefined) {
|
||||
|
|
@ -288,6 +347,7 @@ export function emitJavaScopeCaptures(
|
|||
filePath,
|
||||
captureJavaSpringConfigConsumerFacts(tree.rootNode, filePath),
|
||||
);
|
||||
setJavaSpringDiFacts(filePath, springDiFacts);
|
||||
|
||||
return [
|
||||
...resolveVarTypeBindings(out),
|
||||
|
|
@ -300,8 +360,8 @@ export function emitJavaScopeCaptures(
|
|||
|
||||
/**
|
||||
* Synthesize `@declaration.class` matches for anonymous class bodies
|
||||
* (`new Runnable() { ... }`), named by the same javac-style authority
|
||||
* (`synthesizeJavaAnonymousClassName` → `Worker$N`) the structure phase
|
||||
* (`new Runnable() { ... }`), named by the same javac-compatible authority
|
||||
* (`synthesizeJavaTypeIdentity` → `Worker$N`) the structure phase
|
||||
* uses — the two layers agree by construction (#2550).
|
||||
*
|
||||
* The anchor is the `class_body` node: it shares its range with the
|
||||
|
|
@ -314,13 +374,13 @@ export function emitJavaScopeCaptures(
|
|||
function synthesizeJavaAnonymousClassDeclarations(rootNode: SyntaxNode): CaptureMatch[] {
|
||||
const out: CaptureMatch[] = [];
|
||||
for (const oce of rootNode.descendantsOfType('object_creation_expression')) {
|
||||
const name = synthesizeJavaAnonymousClassName(oce);
|
||||
if (name === undefined) continue;
|
||||
const identity = synthesizeJavaTypeIdentity(oce);
|
||||
if (identity === undefined) continue;
|
||||
const body = oce.namedChildren.find((c) => c.type === 'class_body');
|
||||
if (body === undefined) continue;
|
||||
out.push({
|
||||
'@declaration.class': nodeToCapture('@declaration.class', body),
|
||||
'@declaration.name': syntheticCapture('@declaration.name', body, name),
|
||||
'@declaration.name': syntheticCapture('@declaration.name', body, identity.name),
|
||||
});
|
||||
|
||||
// Inheritance: the anonymous class extends/implements its constructed
|
||||
|
|
@ -361,7 +421,7 @@ function synthesizeJavaAnonymousClassDeclarations(rootNode: SyntaxNode): Capture
|
|||
out.push({
|
||||
'@type-binding.annotation': nodeToCapture('@type-binding.annotation', declNode),
|
||||
'@type-binding.name': nodeToCapture('@type-binding.name', varName),
|
||||
'@type-binding.type': syntheticCapture('@type-binding.type', oce, name),
|
||||
'@type-binding.type': syntheticCapture('@type-binding.type', oce, identity.name),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -377,11 +437,11 @@ function synthesizeJavaAnonymousClassDeclarations(rootNode: SyntaxNode): Capture
|
|||
const hostEnum = javaEnclosingEnumNameOf(constant);
|
||||
const bodyNode = constant.childForFieldName?.('body');
|
||||
const isBodied = bodyNode !== null && bodyNode !== undefined && bodyNode.type === 'class_body';
|
||||
const bodiedName = synthesizeJavaAnonymousClassName(constant);
|
||||
if (bodiedName !== undefined && isBodied) {
|
||||
const bodiedIdentity = synthesizeJavaTypeIdentity(constant);
|
||||
if (bodiedIdentity !== undefined && isBodied) {
|
||||
out.push({
|
||||
'@declaration.class': nodeToCapture('@declaration.class', bodyNode),
|
||||
'@declaration.name': syntheticCapture('@declaration.name', bodyNode, bodiedName),
|
||||
'@declaration.name': syntheticCapture('@declaration.name', bodyNode, bodiedIdentity.name),
|
||||
});
|
||||
if (hostEnum !== undefined) {
|
||||
out.push({
|
||||
|
|
@ -409,7 +469,7 @@ function synthesizeJavaAnonymousClassDeclarations(rootNode: SyntaxNode): Capture
|
|||
// the `object_creation_expression` branch, which skips on synthesis
|
||||
// failure. `hostEnum` is used only for genuinely body-less constants.
|
||||
const constantNameNode = constant.childForFieldName?.('name');
|
||||
const constantType = isBodied ? bodiedName : hostEnum;
|
||||
const constantType = isBodied ? bodiedIdentity?.name : hostEnum;
|
||||
if (constantNameNode !== null && constantNameNode !== undefined && constantType !== undefined) {
|
||||
out.push({
|
||||
'@type-binding.annotation': nodeToCapture('@type-binding.annotation', constant),
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ const JAVA_SCOPE_QUERY = `
|
|||
|
||||
(method_declaration) @scope.function
|
||||
(constructor_declaration) @scope.function
|
||||
(compact_constructor_declaration) @scope.function
|
||||
|
||||
;; Declarations — types
|
||||
(class_declaration
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import {
|
|||
import { populateJavaPackageSiblings } from './package-siblings.js';
|
||||
import { attachSpringBeanCandidateMetadata } from './spring-bean-metadata.js';
|
||||
import { attachJavaSpringConfigBindings } from './spring-config-bindings.js';
|
||||
import { attachJavaSpringDiMetadata } from './spring-di.js';
|
||||
import {
|
||||
applyJavaCaptureSideChannel,
|
||||
clearJavaClassAnnotationFacts,
|
||||
|
|
@ -86,6 +87,7 @@ const javaScopeResolver: ScopeResolver = {
|
|||
populateRangeBindings: populateJavaCrossFileReturnTypes,
|
||||
emitPostResolutionEdges: (graph, parsedFiles, nodeLookup, indexes, ctx) => {
|
||||
attachSpringBeanCandidateMetadata(graph, parsedFiles, nodeLookup, indexes);
|
||||
attachJavaSpringDiMetadata(graph, parsedFiles, nodeLookup, indexes);
|
||||
attachJavaSpringConfigBindings(graph, parsedFiles, nodeLookup, indexes, ctx);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
153
gitnexus/src/core/ingestion/languages/java/spring-di.ts
Normal file
153
gitnexus/src/core/ingestion/languages/java/spring-di.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import { makeScopeId } from 'gitnexus-shared';
|
||||
import {
|
||||
createSpringDiMetadataAttacher,
|
||||
hasSpringDiRelevantAnnotation,
|
||||
hasSpringStereotypeSyntax,
|
||||
type SpringDiAnnotationFact,
|
||||
type SpringDiClassFact,
|
||||
type SpringDiDependencyFact,
|
||||
type SpringDiInjectionSiteFact,
|
||||
} from '../../frameworks/spring/di-metadata.js';
|
||||
import { parseSpringInjectionType } from '../../di-extractors/spring.js';
|
||||
import { nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import { isJavaPackageSiblingVisibilityIncomplete } from './package-siblings.js';
|
||||
import { getJavaSpringDiFacts } from './capture-side-channel.js';
|
||||
|
||||
export type JavaAnnotationSyntaxFact = SpringDiAnnotationFact;
|
||||
|
||||
export type JavaSpringDependencyFact = SpringDiDependencyFact<JavaAnnotationSyntaxFact>;
|
||||
|
||||
type JavaSpringInjectionSiteKind = 'field' | 'constructor' | 'method';
|
||||
|
||||
export type JavaSpringInjectionSiteFact = SpringDiInjectionSiteFact<
|
||||
JavaAnnotationSyntaxFact,
|
||||
JavaSpringInjectionSiteKind
|
||||
>;
|
||||
|
||||
export type JavaSpringDiClassFact = SpringDiClassFact<
|
||||
JavaAnnotationSyntaxFact,
|
||||
JavaSpringInjectionSiteKind
|
||||
>;
|
||||
|
||||
function annotationFacts(node: SyntaxNode): JavaAnnotationSyntaxFact[] {
|
||||
const facts: JavaAnnotationSyntaxFact[] = [];
|
||||
for (const child of node.namedChildren) {
|
||||
if (child.type !== 'modifiers') continue;
|
||||
for (const modifier of child.namedChildren) {
|
||||
if (modifier.type !== 'marker_annotation' && modifier.type !== 'annotation') continue;
|
||||
const nameNode = modifier.childForFieldName('name') ?? modifier.firstNamedChild;
|
||||
if (nameNode === null) continue;
|
||||
facts.push({ name: nameNode.text.trim(), text: modifier.text.trim() });
|
||||
}
|
||||
}
|
||||
return facts;
|
||||
}
|
||||
|
||||
function dependenciesOf(callable: SyntaxNode): JavaSpringDependencyFact[] {
|
||||
const parameters = callable.childForFieldName('parameters');
|
||||
if (parameters === null) return [];
|
||||
const dependencies: JavaSpringDependencyFact[] = [];
|
||||
for (const parameter of parameters.namedChildren) {
|
||||
if (parameter.type !== 'formal_parameter' && parameter.type !== 'spread_parameter') continue;
|
||||
const nameNode = parameter.childForFieldName('name');
|
||||
const typeNode = parameter.childForFieldName('type');
|
||||
if (nameNode === null || typeNode === null) continue;
|
||||
dependencies.push({
|
||||
name: nameNode.text.trim(),
|
||||
rawType: typeNode.text.trim(),
|
||||
annotations: annotationFacts(parameter),
|
||||
});
|
||||
}
|
||||
return dependencies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture one class already surfaced by Java's scope query.
|
||||
*
|
||||
* `captures.ts` calls this from its existing query-match traversal, so Spring
|
||||
* DI does not perform a second recursive walk from the AST root.
|
||||
*/
|
||||
export function captureJavaSpringDiClassFact(
|
||||
classNode: SyntaxNode,
|
||||
filePath: string,
|
||||
): JavaSpringDiClassFact | null {
|
||||
const body = classNode.childForFieldName('body');
|
||||
if (body === null) return null;
|
||||
const classAnnotations = annotationFacts(classNode);
|
||||
const injectionSites: JavaSpringInjectionSiteFact[] = [];
|
||||
|
||||
const constructors = body.namedChildren.filter(
|
||||
(child) => child.type === 'constructor_declaration',
|
||||
);
|
||||
for (const constructor of constructors) {
|
||||
const annotations = annotationFacts(constructor);
|
||||
const implicitConstructor =
|
||||
constructors.length === 1 &&
|
||||
hasSpringStereotypeSyntax(classAnnotations) &&
|
||||
!hasSpringDiRelevantAnnotation(annotations);
|
||||
if (!implicitConstructor && !hasSpringDiRelevantAnnotation(annotations)) continue;
|
||||
injectionSites.push({
|
||||
kind: 'constructor',
|
||||
memberName: constructor.childForFieldName('name')?.text.trim() ?? '<constructor>',
|
||||
implicitConstructor,
|
||||
annotations,
|
||||
dependencies: dependenciesOf(constructor),
|
||||
});
|
||||
}
|
||||
|
||||
for (const member of body.namedChildren) {
|
||||
if (member.type === 'field_declaration') {
|
||||
const annotations = annotationFacts(member);
|
||||
if (!hasSpringDiRelevantAnnotation(annotations)) continue;
|
||||
const typeNode = member.childForFieldName('type');
|
||||
if (typeNode === null) continue;
|
||||
for (const declarator of member.namedChildren) {
|
||||
if (declarator.type !== 'variable_declarator') continue;
|
||||
const nameNode = declarator.childForFieldName('name');
|
||||
if (nameNode === null) continue;
|
||||
injectionSites.push({
|
||||
kind: 'field',
|
||||
memberName: nameNode.text.trim(),
|
||||
implicitConstructor: false,
|
||||
annotations,
|
||||
dependencies: [
|
||||
{
|
||||
name: nameNode.text.trim(),
|
||||
rawType: typeNode.text.trim(),
|
||||
annotations,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
} else if (member.type === 'method_declaration') {
|
||||
const annotations = annotationFacts(member);
|
||||
if (!hasSpringDiRelevantAnnotation(annotations)) continue;
|
||||
injectionSites.push({
|
||||
kind: 'method',
|
||||
memberName: member.childForFieldName('name')?.text.trim() ?? '<method>',
|
||||
implicitConstructor: false,
|
||||
annotations,
|
||||
dependencies: dependenciesOf(member),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (injectionSites.length === 0 && !hasSpringDiRelevantAnnotation(classAnnotations)) return null;
|
||||
const classCapture = nodeToCapture('@spring-di.class', classNode);
|
||||
return {
|
||||
classScopeId: makeScopeId({ filePath, range: classCapture.range, kind: 'Class' }),
|
||||
classAnnotations,
|
||||
injectionSites,
|
||||
};
|
||||
}
|
||||
|
||||
/** Attach resolved, framework-private DI metadata to Class nodes. */
|
||||
export const attachJavaSpringDiMetadata = createSpringDiMetadataAttacher<
|
||||
JavaAnnotationSyntaxFact,
|
||||
JavaSpringInjectionSiteKind
|
||||
>({
|
||||
getFacts: getJavaSpringDiFacts,
|
||||
isPackageVisibilityIncomplete: isJavaPackageSiblingVisibilityIncomplete,
|
||||
parseInjectionType: parseSpringInjectionType,
|
||||
capturedMemberKind: 'field',
|
||||
});
|
||||
|
|
@ -185,10 +185,10 @@ export const kotlinProvider = defineLanguage({
|
|||
emitScopeCaptures: emitKotlinScopeCaptures,
|
||||
// ── #2195 PDG layer: Kotlin CFG visitor (vendored grammar) ──
|
||||
cfgVisitor: createKotlinCfgVisitor(),
|
||||
// Worker-side: snapshot companion-scope marks, package visibility, and
|
||||
// class-annotation facts `emitKotlinScopeCaptures` just populated into plain
|
||||
// data on `ParsedFile.captureSideChannel`, so the main thread can restore all
|
||||
// three via `applyCaptureSideChannel` WITHOUT a re-parse (#1983). See
|
||||
// Worker-side: snapshot companion-scope marks, package visibility, class
|
||||
// annotations, and Spring DI facts `emitKotlinScopeCaptures` just populated
|
||||
// into plain data on `ParsedFile.captureSideChannel`, so the main thread can
|
||||
// restore them via `applyCaptureSideChannel` WITHOUT a re-parse (#1983). See
|
||||
// `kotlin/capture-side-channel.ts`.
|
||||
// `assertCloneable` is a runtime identity; it makes a future non-serializable
|
||||
// value in the side-channel payload a compile error here, at the source, rather
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@
|
|||
* from the `@scope.companion` marker capture.
|
||||
* - Spring Bean class-annotation facts collected during the same scope-query
|
||||
* traversal, consumed only after imports and package visibility finalize.
|
||||
* - Spring DI class facts (constructor/property/method injection syntax),
|
||||
* resolved and attached only after imports finalize.
|
||||
* - A JVM package fact read from the already-parsed root, so package-sibling
|
||||
* visibility never re-parses Kotlin source on the main thread.
|
||||
*
|
||||
|
|
@ -29,7 +31,8 @@
|
|||
* The single generic `ParsedFile.captureSideChannel` field is shared with C++,
|
||||
* which is safe because each file is one language (a `.kt` file uses the kotlin
|
||||
* provider, a `.cpp` file the cpp provider). The payload is self-describing
|
||||
* (`{ kind: 'kotlin', companionScopes, packageFact, classAnnotations }`) so
|
||||
* (`{ kind: 'kotlin', companionScopes, packageFact, classAnnotations,
|
||||
* springDiFacts }`) so
|
||||
* `applyKotlinCaptureSideChannel` only restores kotlin state and ignores a
|
||||
* foreign-shaped snapshot.
|
||||
*/
|
||||
|
|
@ -46,8 +49,10 @@ import {
|
|||
} from '../jvm/package-facts.js';
|
||||
import { getCompanionScopesForFile, markCompanionScope } from './companion-scopes.js';
|
||||
import { getKotlinPackageFact, setKotlinPackageFact } from './package-facts.js';
|
||||
import type { KotlinSpringDiClassFact } from './spring-di.js';
|
||||
|
||||
const classAnnotations = createClassAnnotationFactStore();
|
||||
const springDiFacts = new Map<string, readonly KotlinSpringDiClassFact[]>();
|
||||
|
||||
/**
|
||||
* Plain JSON-serializable snapshot of the per-file Kotlin capture-time
|
||||
|
|
@ -63,10 +68,13 @@ export interface KotlinCaptureSideChannel {
|
|||
readonly packageFact: JvmPackageFact;
|
||||
/** Class annotation syntax collected by the existing scope traversal. */
|
||||
readonly classAnnotations: readonly ClassAnnotationFact[];
|
||||
/** Constructor, property, and method injection syntax captured per class. */
|
||||
readonly springDiFacts?: readonly KotlinSpringDiClassFact[];
|
||||
}
|
||||
|
||||
export function clearKotlinClassAnnotationFacts(): void {
|
||||
classAnnotations.clear();
|
||||
springDiFacts.clear();
|
||||
}
|
||||
|
||||
export function setKotlinClassAnnotationFacts(
|
||||
|
|
@ -80,6 +88,18 @@ export function getKotlinClassAnnotationFacts(filePath: string): readonly ClassA
|
|||
return classAnnotations.get(filePath);
|
||||
}
|
||||
|
||||
export function setKotlinSpringDiFacts(
|
||||
filePath: string,
|
||||
facts: readonly KotlinSpringDiClassFact[],
|
||||
): void {
|
||||
if (facts.length === 0) springDiFacts.delete(filePath);
|
||||
else springDiFacts.set(filePath, facts);
|
||||
}
|
||||
|
||||
export function getKotlinSpringDiFacts(filePath: string): readonly KotlinSpringDiClassFact[] {
|
||||
return springDiFacts.get(filePath) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* `LanguageProvider.collectCaptureSideChannel` implementation for Kotlin.
|
||||
* Returns `undefined` when this file recorded no side-channel state at all, so
|
||||
|
|
@ -90,8 +110,14 @@ export function collectKotlinCaptureSideChannel(
|
|||
): KotlinCaptureSideChannel | undefined {
|
||||
const companionScopes = getCompanionScopesForFile(filePath);
|
||||
const annotationFacts = classAnnotations.get(filePath);
|
||||
const diFacts = springDiFacts.get(filePath) ?? [];
|
||||
const packageFact = getKotlinPackageFact(filePath);
|
||||
if (companionScopes.length === 0 && annotationFacts.length === 0 && packageFact === undefined) {
|
||||
if (
|
||||
companionScopes.length === 0 &&
|
||||
annotationFacts.length === 0 &&
|
||||
diFacts.length === 0 &&
|
||||
packageFact === undefined
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
|
|
@ -99,6 +125,7 @@ export function collectKotlinCaptureSideChannel(
|
|||
companionScopes,
|
||||
packageFact: packageFact ?? UNKNOWN_JVM_PACKAGE_FACT,
|
||||
classAnnotations: annotationFacts,
|
||||
...(diFacts.length > 0 ? { springDiFacts: diFacts } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -121,6 +148,7 @@ export function applyKotlinCaptureSideChannel(parsed: ParsedFile): void {
|
|||
!Array.isArray(data.classAnnotations)
|
||||
) {
|
||||
classAnnotations.set(parsed.filePath, []);
|
||||
setKotlinSpringDiFacts(parsed.filePath, []);
|
||||
setKotlinPackageFact(parsed.filePath, UNKNOWN_JVM_PACKAGE_FACT);
|
||||
return;
|
||||
}
|
||||
|
|
@ -128,6 +156,10 @@ export function applyKotlinCaptureSideChannel(parsed: ParsedFile): void {
|
|||
markCompanionScope(parsed.filePath, scopeId);
|
||||
}
|
||||
classAnnotations.set(parsed.filePath, data.classAnnotations);
|
||||
setKotlinSpringDiFacts(
|
||||
parsed.filePath,
|
||||
Array.isArray(data.springDiFacts) ? data.springDiFacts : [],
|
||||
);
|
||||
setKotlinPackageFact(
|
||||
parsed.filePath,
|
||||
isJvmPackageFact(data.packageFact) ? data.packageFact : UNKNOWN_JVM_PACKAGE_FACT,
|
||||
|
|
|
|||
|
|
@ -18,9 +18,10 @@ import { normalizeKotlinType } from './interpret.js';
|
|||
import { synthesizeKotlinReceiverBinding } from './receiver-binding.js';
|
||||
import { getKotlinParser, getKotlinScopeQuery } from './query.js';
|
||||
import { markCompanionScope } from './companion-scopes.js';
|
||||
import { setKotlinClassAnnotationFacts } from './capture-side-channel.js';
|
||||
import { setKotlinClassAnnotationFacts, setKotlinSpringDiFacts } from './capture-side-channel.js';
|
||||
import { captureKotlinPackageFact } from './package-facts.js';
|
||||
import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js';
|
||||
import { captureKotlinSpringDiClassFact, type KotlinSpringDiClassFact } from './spring-di.js';
|
||||
|
||||
const FUNCTION_DECL_TAGS = ['@declaration.function'] as const;
|
||||
|
||||
|
|
@ -83,6 +84,8 @@ export function emitKotlinScopeCaptures(
|
|||
|
||||
const out: CaptureMatch[] = [];
|
||||
const classAnnotations = new Map<ScopeId, Set<string>>();
|
||||
const springDiFacts: KotlinSpringDiClassFact[] = [];
|
||||
const springDiClassNodeIds = new Set<number>();
|
||||
const returnTypes = collectKotlinReturnTypeTexts(tree.rootNode);
|
||||
out.push(...synthesizeKotlinLocalAssignmentBindings(tree.rootNode, returnTypes));
|
||||
out.push(...synthesizeKotlinLoopBindings(tree.rootNode, returnTypes));
|
||||
|
|
@ -106,6 +109,13 @@ export function emitKotlinScopeCaptures(
|
|||
}
|
||||
if (Object.keys(grouped).length === 0) continue;
|
||||
|
||||
const springDiClassNode = nodeIfType(groupedNodes['@scope.class'], 'class_declaration');
|
||||
if (springDiClassNode !== null && !springDiClassNodeIds.has(springDiClassNode.id)) {
|
||||
springDiClassNodeIds.add(springDiClassNode.id);
|
||||
const fact = captureKotlinSpringDiClassFact(springDiClassNode, filePath);
|
||||
if (fact !== null) springDiFacts.push(fact);
|
||||
}
|
||||
|
||||
const annotatedClass = grouped['@class-annotation.class'];
|
||||
const annotationName = grouped['@class-annotation.name'];
|
||||
if (annotatedClass !== undefined && annotationName !== undefined) {
|
||||
|
|
@ -288,6 +298,7 @@ export function emitKotlinScopeCaptures(
|
|||
}
|
||||
|
||||
setKotlinClassAnnotationFacts(filePath, materializeClassAnnotationFacts(classAnnotations));
|
||||
setKotlinSpringDiFacts(filePath, springDiFacts);
|
||||
out.push(...synthesizeCallableFlowCaptures(tree.rootNode, KOTLIN_CALLABLE_CAPTURE_OPTIONS));
|
||||
return out;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { isKotlinStaticOnly } from './owners.js';
|
|||
import { populateKotlinPackageSiblings } from './package-siblings.js';
|
||||
import { attachKotlinSpringBeanCandidateMetadata } from './spring-bean-metadata.js';
|
||||
import { clearKotlinPackageFacts } from './package-facts.js';
|
||||
import { attachKotlinSpringDiMetadata } from './spring-di.js';
|
||||
|
||||
/**
|
||||
* Kotlin scope resolver for RFC #909 Ring 3.
|
||||
|
|
@ -120,9 +121,13 @@ export const kotlinScopeResolver: ScopeResolver = {
|
|||
propagatesReturnTypesAcrossImports: true,
|
||||
collapseMemberCallsByCallerTarget: false,
|
||||
hoistTypeBindingsToModule: true,
|
||||
freeCallsRequireInstanceOwnership: true,
|
||||
postExtractSourceTextPolicy: 'uncached-files',
|
||||
populateNamespaceSiblings: populateKotlinPackageSiblings,
|
||||
emitPostResolutionEdges: attachKotlinSpringBeanCandidateMetadata,
|
||||
emitPostResolutionEdges: (graph, parsedFiles, nodeLookup, indexes) => {
|
||||
attachKotlinSpringBeanCandidateMetadata(graph, parsedFiles, nodeLookup, indexes);
|
||||
attachKotlinSpringDiMetadata(graph, parsedFiles, nodeLookup, indexes);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
299
gitnexus/src/core/ingestion/languages/kotlin/spring-di.ts
Normal file
299
gitnexus/src/core/ingestion/languages/kotlin/spring-di.ts
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
import { makeScopeId } from 'gitnexus-shared';
|
||||
import { parseSpringInjectionType } from '../../di-extractors/spring.js';
|
||||
import {
|
||||
createSpringDiMetadataAttacher,
|
||||
hasSpringDiRelevantAnnotation,
|
||||
hasSpringStereotypeSyntax,
|
||||
type SpringDiAnnotationFact,
|
||||
type SpringDiClassFact,
|
||||
type SpringDiDependencyFact,
|
||||
type SpringDiInjectionSiteFact,
|
||||
} from '../../frameworks/spring/di-metadata.js';
|
||||
import { nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import { getKotlinSpringDiFacts } from './capture-side-channel.js';
|
||||
import { isKotlinPackageSiblingVisibilityIncomplete } from './package-siblings.js';
|
||||
|
||||
export interface KotlinAnnotationSyntaxFact extends SpringDiAnnotationFact {
|
||||
readonly useSiteTarget?: string;
|
||||
}
|
||||
|
||||
export type KotlinSpringDependencyFact = SpringDiDependencyFact<KotlinAnnotationSyntaxFact>;
|
||||
|
||||
type KotlinSpringInjectionSiteKind = 'property' | 'constructor' | 'method';
|
||||
|
||||
export type KotlinSpringInjectionSiteFact = SpringDiInjectionSiteFact<
|
||||
KotlinAnnotationSyntaxFact,
|
||||
KotlinSpringInjectionSiteKind
|
||||
>;
|
||||
|
||||
export type KotlinSpringDiClassFact = SpringDiClassFact<
|
||||
KotlinAnnotationSyntaxFact,
|
||||
KotlinSpringInjectionSiteKind
|
||||
>;
|
||||
|
||||
const KOTLIN_TYPE_NODES = new Set(['user_type', 'nullable_type', 'function_type']);
|
||||
|
||||
function firstDescendantOfType(node: SyntaxNode, type: string): SyntaxNode | undefined {
|
||||
const stack = [...node.namedChildren].reverse();
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop();
|
||||
if (current === undefined) continue;
|
||||
if (current.type === type) return current;
|
||||
for (let index = current.namedChildren.length - 1; index >= 0; index--) {
|
||||
const child = current.namedChildren[index];
|
||||
if (child !== undefined) stack.push(child);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function annotationFact(annotation: SyntaxNode): KotlinAnnotationSyntaxFact | null {
|
||||
const nameNode = firstDescendantOfType(annotation, 'user_type');
|
||||
if (nameNode === undefined) return null;
|
||||
const useSiteTarget = annotation.namedChildren
|
||||
.find((child) => child.type === 'use_site_target')
|
||||
?.text.replace(/:\s*$/, '')
|
||||
.trim();
|
||||
return {
|
||||
name: nameNode.text.trim(),
|
||||
text: annotation.text.trim(),
|
||||
...(useSiteTarget === undefined || useSiteTarget.length === 0 ? {} : { useSiteTarget }),
|
||||
};
|
||||
}
|
||||
|
||||
function annotationsFromModifierContainer(node: SyntaxNode): KotlinAnnotationSyntaxFact[] {
|
||||
const facts: KotlinAnnotationSyntaxFact[] = [];
|
||||
for (const child of node.namedChildren) {
|
||||
if (child.type !== 'annotation') continue;
|
||||
const fact = annotationFact(child);
|
||||
if (fact !== null) facts.push(fact);
|
||||
}
|
||||
return facts;
|
||||
}
|
||||
|
||||
function annotationFacts(node: SyntaxNode): KotlinAnnotationSyntaxFact[] {
|
||||
const facts: KotlinAnnotationSyntaxFact[] = [];
|
||||
for (const child of node.namedChildren) {
|
||||
if (child.type !== 'modifiers' && child.type !== 'parameter_modifiers') continue;
|
||||
facts.push(...annotationsFromModifierContainer(child));
|
||||
}
|
||||
return facts;
|
||||
}
|
||||
|
||||
function directTypeNode(node: SyntaxNode): SyntaxNode | undefined {
|
||||
return node.namedChildren.find((child) => KOTLIN_TYPE_NODES.has(child.type));
|
||||
}
|
||||
|
||||
function parameterDependency(
|
||||
parameter: SyntaxNode,
|
||||
precedingAnnotations: readonly KotlinAnnotationSyntaxFact[] = [],
|
||||
): KotlinSpringDependencyFact | null {
|
||||
const nameNode = parameter.namedChildren.find((child) => child.type === 'simple_identifier');
|
||||
const typeNode = directTypeNode(parameter);
|
||||
if (nameNode === undefined || typeNode === undefined) return null;
|
||||
return {
|
||||
name: nameNode.text.trim(),
|
||||
rawType: typeNode.text.trim(),
|
||||
annotations: [...precedingAnnotations, ...annotationFacts(parameter)],
|
||||
};
|
||||
}
|
||||
|
||||
function functionDependencies(callable: SyntaxNode): KotlinSpringDependencyFact[] {
|
||||
const parameters = callable.namedChildren.find(
|
||||
(child) => child.type === 'function_value_parameters',
|
||||
);
|
||||
if (parameters === undefined) return [];
|
||||
const dependencies: KotlinSpringDependencyFact[] = [];
|
||||
let pendingAnnotations: KotlinAnnotationSyntaxFact[] = [];
|
||||
for (const child of parameters.namedChildren) {
|
||||
if (child.type === 'parameter_modifiers') {
|
||||
pendingAnnotations = annotationsFromModifierContainer(child);
|
||||
continue;
|
||||
}
|
||||
if (child.type !== 'parameter') continue;
|
||||
const dependency = parameterDependency(child, pendingAnnotations);
|
||||
pendingAnnotations = [];
|
||||
if (dependency !== null) dependencies.push(dependency);
|
||||
}
|
||||
return dependencies;
|
||||
}
|
||||
|
||||
function primaryConstructorDependencies(constructor: SyntaxNode): KotlinSpringDependencyFact[] {
|
||||
const dependencies: KotlinSpringDependencyFact[] = [];
|
||||
for (const parameter of constructor.namedChildren) {
|
||||
if (parameter.type !== 'class_parameter') continue;
|
||||
const dependency = parameterDependency(parameter);
|
||||
if (dependency !== null) dependencies.push(dependency);
|
||||
}
|
||||
return dependencies;
|
||||
}
|
||||
|
||||
function propertyDependency(property: SyntaxNode): KotlinSpringDependencyFact | null {
|
||||
const variable = property.namedChildren.find((child) => child.type === 'variable_declaration');
|
||||
if (variable === undefined) return null;
|
||||
const nameNode = variable.namedChildren.find((child) => child.type === 'simple_identifier');
|
||||
const typeNode = directTypeNode(variable);
|
||||
if (nameNode === undefined || typeNode === undefined) return null;
|
||||
const annotations = annotationFacts(property);
|
||||
return {
|
||||
name: nameNode.text.trim(),
|
||||
rawType: typeNode.text.trim(),
|
||||
annotations,
|
||||
};
|
||||
}
|
||||
|
||||
function isKotlinBeanCandidateClass(classNode: SyntaxNode): boolean {
|
||||
if (classNode.children.some((child) => child.type === 'interface' || child.type === 'enum')) {
|
||||
return false;
|
||||
}
|
||||
const modifiers = classNode.namedChildren.find((child) => child.type === 'modifiers');
|
||||
return !modifiers?.namedChildren.some(
|
||||
(child) => child.type === 'class_modifier' && child.text.trim() === 'annotation',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture one class already surfaced by Kotlin's scope query. Kotlin-specific
|
||||
* syntax is normalized here while import/FQN semantics remain deferred until
|
||||
* post-resolution.
|
||||
*/
|
||||
export function captureKotlinSpringDiClassFact(
|
||||
classNode: SyntaxNode,
|
||||
filePath: string,
|
||||
): KotlinSpringDiClassFact | null {
|
||||
if (!isKotlinBeanCandidateClass(classNode)) return null;
|
||||
const classAnnotations = annotationFacts(classNode);
|
||||
const injectionSites: KotlinSpringInjectionSiteFact[] = [];
|
||||
const body = classNode.namedChildren.find((child) => child.type === 'class_body');
|
||||
const primaryConstructor = classNode.namedChildren.find(
|
||||
(child) => child.type === 'primary_constructor',
|
||||
);
|
||||
const secondaryConstructors =
|
||||
body?.namedChildren.filter((child) => child.type === 'secondary_constructor') ?? [];
|
||||
const constructorCount =
|
||||
(primaryConstructor === undefined ? 0 : 1) + secondaryConstructors.length;
|
||||
|
||||
if (primaryConstructor !== undefined) {
|
||||
const annotations = annotationFacts(primaryConstructor);
|
||||
const implicitConstructor =
|
||||
constructorCount === 1 &&
|
||||
hasSpringStereotypeSyntax(classAnnotations) &&
|
||||
!hasSpringDiRelevantAnnotation(annotations);
|
||||
if (implicitConstructor || hasSpringDiRelevantAnnotation(annotations)) {
|
||||
injectionSites.push({
|
||||
kind: 'constructor',
|
||||
memberName: '<primary-constructor>',
|
||||
implicitConstructor,
|
||||
annotations,
|
||||
dependencies: primaryConstructorDependencies(primaryConstructor),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const constructor of secondaryConstructors) {
|
||||
const annotations = annotationFacts(constructor);
|
||||
const implicitConstructor =
|
||||
constructorCount === 1 &&
|
||||
hasSpringStereotypeSyntax(classAnnotations) &&
|
||||
!hasSpringDiRelevantAnnotation(annotations);
|
||||
if (!implicitConstructor && !hasSpringDiRelevantAnnotation(annotations)) continue;
|
||||
injectionSites.push({
|
||||
kind: 'constructor',
|
||||
memberName: '<secondary-constructor>',
|
||||
implicitConstructor,
|
||||
annotations,
|
||||
dependencies: functionDependencies(constructor),
|
||||
});
|
||||
}
|
||||
|
||||
if (body !== undefined) {
|
||||
for (const member of body.namedChildren) {
|
||||
if (member.type === 'property_declaration') {
|
||||
const annotations = annotationFacts(member);
|
||||
if (!hasSpringDiRelevantAnnotation(annotations)) continue;
|
||||
const dependency = propertyDependency(member);
|
||||
if (dependency === null) continue;
|
||||
injectionSites.push({
|
||||
kind: 'property',
|
||||
memberName: dependency.name,
|
||||
implicitConstructor: false,
|
||||
annotations,
|
||||
dependencies: [dependency],
|
||||
});
|
||||
} else if (member.type === 'function_declaration') {
|
||||
const annotations = annotationFacts(member);
|
||||
if (!hasSpringDiRelevantAnnotation(annotations)) continue;
|
||||
const name =
|
||||
member.namedChildren.find((child) => child.type === 'simple_identifier')?.text.trim() ??
|
||||
'<method>';
|
||||
injectionSites.push({
|
||||
kind: 'method',
|
||||
memberName: name,
|
||||
implicitConstructor: false,
|
||||
annotations,
|
||||
dependencies: functionDependencies(member),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (injectionSites.length === 0 && !hasSpringDiRelevantAnnotation(classAnnotations)) return null;
|
||||
const classCapture = nodeToCapture('@spring-di.class', classNode);
|
||||
return {
|
||||
classScopeId: makeScopeId({ filePath, range: classCapture.range, kind: 'Class' }),
|
||||
classAnnotations,
|
||||
injectionSites,
|
||||
};
|
||||
}
|
||||
|
||||
function isApplicableInjectionAnnotation(
|
||||
annotation: KotlinAnnotationSyntaxFact,
|
||||
site: KotlinSpringInjectionSiteFact,
|
||||
): boolean {
|
||||
if (annotation.useSiteTarget === undefined) return true;
|
||||
if (site.kind === 'constructor') return annotation.useSiteTarget === 'constructor';
|
||||
if (site.kind === 'property') {
|
||||
return annotation.useSiteTarget === 'field' || annotation.useSiteTarget === 'set';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isApplicableQualifierAnnotation(
|
||||
annotation: KotlinAnnotationSyntaxFact,
|
||||
site: KotlinSpringInjectionSiteFact,
|
||||
): boolean {
|
||||
if (annotation.useSiteTarget === undefined) return true;
|
||||
if (site.kind === 'property') {
|
||||
return (
|
||||
annotation.useSiteTarget === 'field' ||
|
||||
annotation.useSiteTarget === 'param' ||
|
||||
annotation.useSiteTarget === 'setparam'
|
||||
);
|
||||
}
|
||||
return annotation.useSiteTarget === 'param';
|
||||
}
|
||||
|
||||
function parseKotlinSpringInjectionType(rawType: string) {
|
||||
// Kotlin nullable suffixes, type projections, and mutable collection aliases
|
||||
// do not change the JVM bean type selected by Spring. Normalize only those
|
||||
// surface forms; stars, function types, arrays, and nested generic elements
|
||||
// still fail closed in the shared parser.
|
||||
const normalized = rawType
|
||||
.replace(/\bMutable(List|Set|Collection|Map)(?=\s*<)/g, '$1')
|
||||
.replace(/([<,])\s*(?:out|in)\s+/g, '$1')
|
||||
.replace(/\?(?=\s*(?:[>,]|$))/g, '');
|
||||
return parseSpringInjectionType(normalized);
|
||||
}
|
||||
|
||||
/** Attach resolved, framework-private DI metadata to Kotlin Class nodes. */
|
||||
export const attachKotlinSpringDiMetadata = createSpringDiMetadataAttacher<
|
||||
KotlinAnnotationSyntaxFact,
|
||||
KotlinSpringInjectionSiteKind
|
||||
>({
|
||||
getFacts: getKotlinSpringDiFacts,
|
||||
isPackageVisibilityIncomplete: isKotlinPackageSiblingVisibilityIncomplete,
|
||||
parseInjectionType: parseKotlinSpringInjectionType,
|
||||
capturedMemberKind: 'property',
|
||||
isInjectionAnnotationApplicable: isApplicableInjectionAnnotation,
|
||||
isQualifierAnnotationApplicable: isApplicableQualifierAnnotation,
|
||||
});
|
||||
|
|
@ -22,6 +22,15 @@ const PYTHON_SCOPE_QUERY = `
|
|||
(function_definition
|
||||
name: (identifier) @declaration.name) @declaration.function
|
||||
|
||||
;; Lambda bindings (\`f = lambda x: x\`). The \`@declaration.function\` anchor
|
||||
;; sits on the INNER lambda so its range aligns with \`(lambda) @scope.function\`
|
||||
;; above; otherwise the def is owned by the module scope and calls inside the
|
||||
;; lambda lose caller attribution. Mirrors the TypeScript arrow patterns (#2687).
|
||||
(expression_statement
|
||||
(assignment
|
||||
left: (identifier) @declaration.name
|
||||
right: (lambda) @declaration.function))
|
||||
|
||||
(assignment
|
||||
left: (identifier) @declaration.name) @declaration.variable
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { getTreeSitterBufferSize } from '../../constants.js';
|
|||
import { parseSourceSafe, ParseTimeoutError } from '../../../tree-sitter/safe-parse.js';
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import { logger } from '../../../logger.js';
|
||||
import { lookupBindingsAt } from '../../scope-resolution/scope/walkers.js';
|
||||
|
||||
/**
|
||||
* Populate type bindings for patterns and iterators that the tree-sitter
|
||||
|
|
@ -16,9 +17,54 @@ import { logger } from '../../../logger.js';
|
|||
* Runs in Phase 2 (after propagateImportedReturnTypes) so all cross-file
|
||||
* type bindings are available for lookup.
|
||||
*/
|
||||
type RustTree = ReturnType<ReturnType<typeof getRustParser>['parse']>;
|
||||
|
||||
/**
|
||||
* Hold parsed trees for reuse across both prepass loops only when the whole
|
||||
* Rust source fits this budget. Trees are much larger than their source, so a
|
||||
* modest source cap keeps peak held-tree memory bounded; larger repos fall
|
||||
* back to re-parsing per loop (unchanged RSS).
|
||||
*/
|
||||
const TREE_REUSE_SOURCE_BUDGET_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Parse `filePath`'s source once, honoring the caller's `treeCache` and, when
|
||||
* provided, an in-function `store` so the two prepass loops share a single
|
||||
* parse instead of re-parsing every file. Returns null when the source is
|
||||
* missing or parsing times out.
|
||||
*/
|
||||
function getOrParseTree(
|
||||
parser: ReturnType<typeof getRustParser>,
|
||||
filePath: string,
|
||||
ctx: {
|
||||
readonly fileContents: ReadonlyMap<string, string>;
|
||||
readonly treeCache?: { get(filePath: string): unknown };
|
||||
},
|
||||
store: Map<string, RustTree> | undefined,
|
||||
): RustTree | null {
|
||||
const cached = (ctx.treeCache?.get(filePath) ?? store?.get(filePath)) as RustTree | undefined;
|
||||
if (cached !== undefined) return cached;
|
||||
const sourceText = ctx.fileContents.get(filePath);
|
||||
if (sourceText === undefined) return null;
|
||||
let tree: RustTree;
|
||||
try {
|
||||
tree = parseSourceSafe(parser, sourceText, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(sourceText),
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof ParseTimeoutError) {
|
||||
logger.warn({ file: filePath }, 'rust range-binding: parse timed out, skipping file');
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
store?.set(filePath, tree);
|
||||
return tree;
|
||||
}
|
||||
|
||||
export function populateRustRangeBindings(
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
_indexes: ScopeResolutionIndexes,
|
||||
indexes: ScopeResolutionIndexes,
|
||||
ctx: {
|
||||
readonly fileContents: ReadonlyMap<string, string>;
|
||||
readonly treeCache?: { get(filePath: string): unknown };
|
||||
|
|
@ -26,45 +72,45 @@ export function populateRustRangeBindings(
|
|||
): void {
|
||||
const parser = getRustParser();
|
||||
const allReturnTypes = new Map<string, string>();
|
||||
const ambiguousReturnTypes = new Set<string>();
|
||||
const allFieldTypes = new Map<string, Map<string, string>>();
|
||||
const ambiguousFieldTypes = new Set<string>();
|
||||
// Per-defining-file, un-collapsed, FULL-generic return/field types. When a
|
||||
// bare name is ambiguous (#2514) but the call site's `use` import pins a
|
||||
// single definition, we resolve that definition's file here and read its
|
||||
// untruncated type so a generic `Vec<Repo>` element type survives (#2514
|
||||
// follow-up: import-disambiguated duplicates resolve like the compiler).
|
||||
const returnTypeByFile = new Map<string, Map<string, string>>();
|
||||
const fieldTypeByFile = new Map<string, Map<string, Map<string, string>>>();
|
||||
// Parse each file once and reuse across both loops when the workspace fits
|
||||
// the byte budget; otherwise re-parse per loop to bound RSS (see helper).
|
||||
let totalSourceBytes = 0;
|
||||
for (const parsed of parsedFiles) {
|
||||
totalSourceBytes += ctx.fileContents.get(parsed.filePath)?.length ?? 0;
|
||||
}
|
||||
const treeStore: Map<string, RustTree> | undefined =
|
||||
totalSourceBytes <= TREE_REUSE_SOURCE_BUDGET_BYTES ? new Map() : undefined;
|
||||
|
||||
for (const parsed of parsedFiles) {
|
||||
const sourceText = ctx.fileContents.get(parsed.filePath);
|
||||
if (sourceText === undefined) continue;
|
||||
|
||||
const cachedTree = ctx.treeCache?.get(parsed.filePath) as
|
||||
| ReturnType<typeof parser.parse>
|
||||
| undefined;
|
||||
let tree: ReturnType<typeof parser.parse>;
|
||||
if (cachedTree !== undefined) {
|
||||
tree = cachedTree;
|
||||
} else {
|
||||
try {
|
||||
tree = parseSourceSafe(parser, sourceText, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(sourceText),
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof ParseTimeoutError) {
|
||||
logger.warn(
|
||||
{ file: parsed.filePath },
|
||||
'rust range-binding: parse timed out, skipping file',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
const tree = getOrParseTree(parser, parsed.filePath, ctx, treeStore);
|
||||
if (tree === null) continue;
|
||||
|
||||
for (const fn of tree.rootNode.descendantsOfType('function_item')) {
|
||||
const nameNode = fn.childForFieldName('name');
|
||||
const retType = fn.childForFieldName('return_type');
|
||||
if (nameNode !== null && retType !== null) {
|
||||
const name = nameNode.text;
|
||||
// Ambiguity is a latch, not a toggle: once a name has two or more
|
||||
// workspace definitions it stays ambiguous for the rest of the
|
||||
// prepass, regardless of duplicate count or file order (#2514).
|
||||
if (allReturnTypes.has(name)) {
|
||||
allReturnTypes.delete(name);
|
||||
} else {
|
||||
ambiguousReturnTypes.add(name);
|
||||
} else if (!ambiguousReturnTypes.has(name)) {
|
||||
allReturnTypes.set(name, retType.text);
|
||||
}
|
||||
// Full-generic record per defining file for import-disambiguated lookup.
|
||||
recordByFile(returnTypeByFile, parsed.filePath, name, retType.text);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -82,11 +128,16 @@ export function populateRustRangeBindings(
|
|||
}
|
||||
if (fields.size > 0) {
|
||||
const name = nameNode.text;
|
||||
// Same ambiguity latch as return types (#2514): a third same-named
|
||||
// struct must not restore a resolvable global field map.
|
||||
if (allFieldTypes.has(name)) {
|
||||
allFieldTypes.delete(name);
|
||||
} else {
|
||||
ambiguousFieldTypes.add(name);
|
||||
} else if (!ambiguousFieldTypes.has(name)) {
|
||||
allFieldTypes.set(name, fields);
|
||||
}
|
||||
// Full-generic record per defining file for import-disambiguated lookup.
|
||||
recordByFile(fieldTypeByFile, parsed.filePath, name, fields);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -99,39 +150,32 @@ export function populateRustRangeBindings(
|
|||
}
|
||||
|
||||
for (const parsed of parsedFiles) {
|
||||
const sourceText = ctx.fileContents.get(parsed.filePath);
|
||||
if (sourceText === undefined) continue;
|
||||
|
||||
const cachedTree = ctx.treeCache?.get(parsed.filePath) as
|
||||
| ReturnType<typeof parser.parse>
|
||||
| undefined;
|
||||
let tree: ReturnType<typeof parser.parse>;
|
||||
if (cachedTree !== undefined) {
|
||||
tree = cachedTree;
|
||||
} else {
|
||||
try {
|
||||
tree = parseSourceSafe(parser, sourceText, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(sourceText),
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof ParseTimeoutError) {
|
||||
logger.warn(
|
||||
{ file: parsed.filePath },
|
||||
'rust range-binding: parse timed out, skipping file',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
const tree = getOrParseTree(parser, parsed.filePath, ctx, treeStore);
|
||||
if (tree === null) continue;
|
||||
|
||||
const scopeMap = new Map(parsed.scopes.map((s) => [s.id, s]));
|
||||
const moduleScope = parsed.scopes.find((s) => s.kind === 'Module');
|
||||
if (moduleScope === undefined) continue;
|
||||
|
||||
processForLoops(tree.rootNode, parsed, scopeMap, moduleScope, allReturnTypes);
|
||||
processForLoops(
|
||||
tree.rootNode,
|
||||
parsed,
|
||||
scopeMap,
|
||||
moduleScope,
|
||||
allReturnTypes,
|
||||
indexes,
|
||||
returnTypeByFile,
|
||||
);
|
||||
processPatternBindings(tree.rootNode, parsed, scopeMap, moduleScope);
|
||||
processStructDestructuring(tree.rootNode, parsed, scopeMap, moduleScope, allFieldTypes);
|
||||
processStructDestructuring(
|
||||
tree.rootNode,
|
||||
parsed,
|
||||
scopeMap,
|
||||
moduleScope,
|
||||
allFieldTypes,
|
||||
indexes,
|
||||
fieldTypeByFile,
|
||||
);
|
||||
processPendingAssignments(
|
||||
tree.rootNode,
|
||||
parsed,
|
||||
|
|
@ -196,12 +240,88 @@ function normalizeFieldType(text: string): string {
|
|||
return t.trim();
|
||||
}
|
||||
|
||||
/** Get-or-create the inner map for `file` and record `name -> value`. */
|
||||
function recordByFile<V>(
|
||||
byFile: Map<string, Map<string, V>>,
|
||||
file: string,
|
||||
name: string,
|
||||
value: V,
|
||||
): void {
|
||||
let inner = byFile.get(file);
|
||||
if (inner === undefined) {
|
||||
inner = new Map<string, V>();
|
||||
byFile.set(file, inner);
|
||||
}
|
||||
inner.set(name, value);
|
||||
}
|
||||
|
||||
/** Final segment of a dot-joined qualified name (`a.make` -> `make`), or the
|
||||
* bare name when the def carries no qualifier. */
|
||||
function simpleName(qualifiedName: string | undefined, bareName: string): string {
|
||||
if (qualifiedName === undefined) return bareName;
|
||||
const dot = qualifiedName.lastIndexOf('.');
|
||||
return dot === -1 ? qualifiedName : qualifiedName.slice(dot + 1);
|
||||
}
|
||||
|
||||
/** Distinct `(file, name)` definitions, in first-seen order. */
|
||||
function uniqueDefs(
|
||||
defs: readonly { file: string; name: string }[],
|
||||
): { file: string; name: string }[] {
|
||||
const seen = new Set<string>();
|
||||
const out: { file: string; name: string }[] = [];
|
||||
for (const d of defs) {
|
||||
const key = `${d.file} ${d.name}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(d);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve `name` at `moduleScope` to the value recorded in `byFile` for the one
|
||||
* definition visible here, or null when zero or several are visible (which
|
||||
* keeps the #2514 ambiguity latch). Mirrors Rust name resolution: explicit
|
||||
* `use`/re-export imports and local defs shadow `use x::*` globs, so a glob is
|
||||
* consulted only when no explicit binding names `name`, and even then only when
|
||||
* exactly one glob-target file actually defines it.
|
||||
*/
|
||||
function resolveImportedDef<V>(
|
||||
name: string,
|
||||
moduleScope: Scope,
|
||||
indexes: ScopeResolutionIndexes,
|
||||
byFile: ReadonlyMap<string, ReadonlyMap<string, V>>,
|
||||
): V | null {
|
||||
const explicit = uniqueDefs(
|
||||
lookupBindingsAt(moduleScope.id, name, indexes)
|
||||
.filter((r) => r.origin === 'import' || r.origin === 'reexport' || r.origin === 'local')
|
||||
.map((r) => ({ file: r.def.filePath, name: simpleName(r.def.qualifiedName, name) })),
|
||||
);
|
||||
const defs =
|
||||
explicit.length > 0
|
||||
? explicit
|
||||
: uniqueDefs(
|
||||
(indexes.imports.get(moduleScope.id) ?? [])
|
||||
.filter(
|
||||
(e) =>
|
||||
e.kind === 'wildcard-expanded' &&
|
||||
e.targetFile !== null &&
|
||||
byFile.get(e.targetFile)?.has(name) === true,
|
||||
)
|
||||
.map((e) => ({ file: e.targetFile as string, name })),
|
||||
);
|
||||
if (defs.length !== 1) return null;
|
||||
return byFile.get(defs[0].file)?.get(defs[0].name) ?? null;
|
||||
}
|
||||
|
||||
function processForLoops(
|
||||
root: SyntaxNode,
|
||||
parsed: ParsedFile,
|
||||
scopeMap: ReadonlyMap<string, Scope>,
|
||||
moduleScope: Scope,
|
||||
allReturnTypes: ReadonlyMap<string, string>,
|
||||
indexes: ScopeResolutionIndexes,
|
||||
returnTypeByFile: ReadonlyMap<string, Map<string, string>>,
|
||||
): void {
|
||||
for (const forNode of root.descendantsOfType('for_expression')) {
|
||||
const patternNode = forNode.childForFieldName('pattern');
|
||||
|
|
@ -217,6 +337,8 @@ function processForLoops(
|
|||
scopeMap,
|
||||
moduleScope,
|
||||
allReturnTypes,
|
||||
indexes,
|
||||
returnTypeByFile,
|
||||
);
|
||||
if (elementType === null) continue;
|
||||
|
||||
|
|
@ -331,7 +453,9 @@ function processStructDestructuring(
|
|||
parsed: ParsedFile,
|
||||
scopeMap: ReadonlyMap<string, Scope>,
|
||||
moduleScope: Scope,
|
||||
allFieldTypes?: ReadonlyMap<string, Map<string, string>>,
|
||||
allFieldTypes: ReadonlyMap<string, Map<string, string>>,
|
||||
indexes: ScopeResolutionIndexes,
|
||||
fieldTypeByFile: ReadonlyMap<string, ReadonlyMap<string, Map<string, string>>>,
|
||||
): void {
|
||||
for (const letNode of root.descendantsOfType('let_declaration')) {
|
||||
const patternNode = letNode.childForFieldName('pattern');
|
||||
|
|
@ -356,7 +480,13 @@ function processStructDestructuring(
|
|||
|
||||
let fieldType = lookupFieldType(typeName, fieldName, parsed, scopeMap, moduleScope);
|
||||
if (fieldType === null) {
|
||||
fieldType = allFieldTypes?.get(typeName)?.get(fieldName) ?? null;
|
||||
fieldType = allFieldTypes.get(typeName)?.get(fieldName) ?? null;
|
||||
}
|
||||
if (fieldType === null) {
|
||||
// Import-disambiguated duplicate struct (#2514 follow-up): the global
|
||||
// field map is ambiguous, but a `use` import pins one definition.
|
||||
const fields = resolveImportedDef(typeName, moduleScope, indexes, fieldTypeByFile);
|
||||
fieldType = fields?.get(fieldName) ?? null;
|
||||
}
|
||||
if (fieldType !== null) {
|
||||
injectTypeBinding(targetScope, fieldName, fieldType);
|
||||
|
|
@ -481,7 +611,9 @@ function resolveIterableElementType(
|
|||
parsed: ParsedFile,
|
||||
scopeMap: ReadonlyMap<string, Scope>,
|
||||
moduleScope: Scope,
|
||||
allReturnTypes?: ReadonlyMap<string, string>,
|
||||
allReturnTypes: ReadonlyMap<string, string>,
|
||||
indexes: ScopeResolutionIndexes,
|
||||
returnTypeByFile: ReadonlyMap<string, ReadonlyMap<string, string>>,
|
||||
): string | null {
|
||||
let iterableNode = valueNode;
|
||||
if (iterableNode.type === 'reference_expression') {
|
||||
|
|
@ -506,10 +638,16 @@ function resolveIterableElementType(
|
|||
}
|
||||
|
||||
if (func.type === 'identifier') {
|
||||
const crossFileReturn = allReturnTypes?.get(func.text);
|
||||
const crossFileReturn = allReturnTypes.get(func.text);
|
||||
if (crossFileReturn !== undefined) return unwrapGeneric(crossFileReturn);
|
||||
const rawReturn = lookupRawFunctionReturnType(func.text, valueNode);
|
||||
if (rawReturn !== null) return unwrapGeneric(rawReturn);
|
||||
// Import-disambiguated duplicate: the bare-name map is ambiguous (#2514)
|
||||
// but a `use` import pins one definition. Read its FULL return type
|
||||
// here, BEFORE the scope-binding lookup below, because that binding is
|
||||
// generic-truncated (`Vec<Repo>` becomes `Vec`), losing the element.
|
||||
const importedReturn = resolveImportedDef(func.text, moduleScope, indexes, returnTypeByFile);
|
||||
if (importedReturn !== null) return unwrapGeneric(importedReturn);
|
||||
const returnType = lookupReturnTypeInScopes(func.text, parsed, scopeMap, moduleScope);
|
||||
if (returnType !== null) return unwrapGeneric(returnType);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { GraphNode, GraphRelationship, NodeLabel } from 'gitnexus-shared';
|
||||
import type { GraphNode, NodeLabel, RelationshipType } from 'gitnexus-shared';
|
||||
import type { KnowledgeGraph } from '../graph/types.js';
|
||||
import { parseTruthyEnv } from './utils/env.js';
|
||||
|
||||
|
|
@ -30,9 +30,13 @@ const isLocalValueCandidate = (node: GraphNode): boolean => {
|
|||
// True when `rel` is the structural `File -> DEFINES -> candidate` edge. Callers
|
||||
// guard on the candidate already being the edge target, so only the source label
|
||||
// needs checking here.
|
||||
const isFileDefinesEdge = (graph: KnowledgeGraph, rel: GraphRelationship): boolean => {
|
||||
if (rel.type !== 'DEFINES') return false;
|
||||
return graph.getNode(rel.sourceId)?.label === 'File';
|
||||
const isFileDefinesEdge = (
|
||||
graph: KnowledgeGraph,
|
||||
type: RelationshipType,
|
||||
sourceId: string,
|
||||
): boolean => {
|
||||
if (type !== 'DEFINES') return false;
|
||||
return graph.getNode(sourceId)?.label === 'File';
|
||||
};
|
||||
|
||||
export const pruneLocalValueSymbols = (
|
||||
|
|
@ -51,21 +55,21 @@ export const pruneLocalValueSymbols = (
|
|||
if (candidateIds.size === 0) return emptyStats(false);
|
||||
|
||||
const candidatesWithSemanticEdges = new Set<string>();
|
||||
for (const rel of graph.iterRelationships()) {
|
||||
// Field-wise scan (#2680): a whole-graph walk that reads only these three, so
|
||||
// materializing a relationship object per edge would be pure overhead.
|
||||
graph.forEachRelationshipFields((sourceId, targetId, type) => {
|
||||
// Any outgoing edge from a candidate is a semantic edge: the only structural
|
||||
// edge a block-local value symbol carries is the incoming File -> DEFINES, on
|
||||
// which the candidate is the target, never the source.
|
||||
if (candidateIds.has(rel.sourceId)) {
|
||||
candidatesWithSemanticEdges.add(rel.sourceId);
|
||||
if (candidateIds.has(sourceId)) {
|
||||
candidatesWithSemanticEdges.add(sourceId);
|
||||
}
|
||||
|
||||
// An incoming edge is semantic unless it is the structural File -> DEFINES.
|
||||
if (candidateIds.has(rel.targetId)) {
|
||||
if (!isFileDefinesEdge(graph, rel)) {
|
||||
candidatesWithSemanticEdges.add(rel.targetId);
|
||||
}
|
||||
if (candidateIds.has(targetId) && !isFileDefinesEdge(graph, type, sourceId)) {
|
||||
candidatesWithSemanticEdges.add(targetId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let prunedNodes = 0;
|
||||
for (const candidateId of candidateIds) {
|
||||
|
|
|
|||
|
|
@ -1,91 +1,91 @@
|
|||
/**
|
||||
* Phase: di
|
||||
*
|
||||
* Framework-neutral dependency-injection resolution. Routes `Property` nodes
|
||||
* by `properties.language` to the per-language field matchers registered in
|
||||
* `di-extractors/` (`DI_MATCHERS` — same registry seam shape as
|
||||
* `SCOPE_RESOLVERS`), then fans each match out to `INJECTS` edges from the
|
||||
* consumer Class node to every Class implementing the matched element
|
||||
* interface.
|
||||
*
|
||||
* This file names NO language or framework: which fields count as
|
||||
* container-injected — and why — is entirely the registered matcher's
|
||||
* business (see `di-extractors/` for the matchers and their semantics,
|
||||
* including deliberate annotation exclusions). The matcher also supplies the
|
||||
* human-readable edge `reason`, so framework specifics stay in the payload,
|
||||
* never in this phase.
|
||||
*
|
||||
* The resolution uses ONLY graph data — Property nodes, `HAS_PROPERTY` edges,
|
||||
* `IMPLEMENTS` edges, and Interface nodes. No filesystem access is performed:
|
||||
* the structural information was already extracted by earlier parse /
|
||||
* structure phases.
|
||||
*
|
||||
* Interface resolution is scoped to the CANDIDATE'S OWN language and prefers
|
||||
* qualified names: a dotted element type resolves via the language's
|
||||
* `qualifiedName` index; a bare simple name resolves only while unique within
|
||||
* that language. Ambiguous names — simple OR qualified (a qualifiedName has
|
||||
* no file-path component, so the same package+name duplicated across monorepo
|
||||
* modules collides too) — fail CLOSED — no edge, never
|
||||
* last-writer-wins — but observably: skips are counted in the phase output's
|
||||
* `ambiguousSkipped` and named in an isDev debug log, so "no DI fields" is
|
||||
* distinguishable from "all candidates ambiguous". Same-package/import-aware
|
||||
* disambiguation is a documented follow-up (see the plan's Deferred work).
|
||||
* Framework-neutral dependency-injection resolution. Per-language resolvers
|
||||
* identify injection sites and provider metadata; this phase performs only
|
||||
* graph-level type/heritage resolution and emits Class -> Class INJECTS edges.
|
||||
*
|
||||
* @deps mro
|
||||
* @reads graph (Property nodes, HAS_PROPERTY edges, IMPLEMENTS edges, Interface nodes)
|
||||
* @reads graph (Class/Interface/member nodes and heritage/ownership edges)
|
||||
* @writes graph (INJECTS edges)
|
||||
*/
|
||||
|
||||
import type { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type { GraphNode, SupportedLanguages } from 'gitnexus-shared';
|
||||
import type { PipelinePhase, PipelineContext } from './types.js';
|
||||
import { DI_MATCHERS, isSupportedLanguage } from '../di-extractors/index.js';
|
||||
import {
|
||||
DI_RESOLVERS,
|
||||
isSupportedLanguage,
|
||||
type DiInjectionMatch,
|
||||
type DiProviderMatch,
|
||||
} from '../di-extractors/index.js';
|
||||
import { isDev } from '../utils/env.js';
|
||||
import { logger } from '../../logger.js';
|
||||
|
||||
export interface DIOutput {
|
||||
injectsEdges: number;
|
||||
/** Kept for output compatibility; now counts every matched injection site. */
|
||||
fieldsScanned: number;
|
||||
/** Candidates skipped because their element type name — bare simple name
|
||||
* or dotted qualified name — matched more than one Interface within the
|
||||
* candidate's language (fail-closed). */
|
||||
/** Sites skipped because the requested type name itself was ambiguous. */
|
||||
ambiguousSkipped: number;
|
||||
/** Single-valued sites represented by multiple low-confidence candidates. */
|
||||
ambiguousInjections: number;
|
||||
}
|
||||
|
||||
/** Sentinel marking an interface name (simple or qualified) claimed by more
|
||||
* than one Interface node within a language — resolution must fail closed. */
|
||||
const AMBIGUOUS: unique symbol = Symbol('ambiguous');
|
||||
|
||||
/** Per-language interface lookup: qualified names resolve exactly; bare
|
||||
* simple names resolve only while unique within the language. Both indexes
|
||||
* fail closed on their own duplicates. */
|
||||
interface InterfaceIndex {
|
||||
/** `properties.qualifiedName` → Interface node id (when extracted — e.g.
|
||||
* package-qualified for languages with a file-scope package declaration),
|
||||
* or {@link AMBIGUOUS} once a second Interface claims the same qualified
|
||||
* name in the same language — realistic in monorepos, where the same
|
||||
* package+name is duplicated across modules or main/test source roots
|
||||
* (a qualifiedName carries no file-path component). */
|
||||
interface NameIndex {
|
||||
byQualifiedName: Map<string, string | typeof AMBIGUOUS>;
|
||||
/** `properties.name` → Interface node id, or {@link AMBIGUOUS} once a
|
||||
* second same-name Interface appears in the same language. */
|
||||
bySimpleName: Map<string, string | typeof AMBIGUOUS>;
|
||||
}
|
||||
|
||||
/** A Property node a registered matcher accepted as a DI fan-out candidate. */
|
||||
interface CandidateField {
|
||||
propertyId: string;
|
||||
/** The candidate's language — interface resolution (Pass 3) looks up ONLY
|
||||
* this language's interface index. */
|
||||
interface CandidateSite extends DiInjectionMatch {
|
||||
siteNodeId: string;
|
||||
language: SupportedLanguages;
|
||||
elementTypeName: string;
|
||||
/** Matcher-supplied edge reason (carries the framework specifics). */
|
||||
}
|
||||
|
||||
interface PendingEdge {
|
||||
sourceId: string;
|
||||
targetId: string;
|
||||
confidence: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
function emptyNameIndex(): NameIndex {
|
||||
return { byQualifiedName: new Map(), bySimpleName: new Map() };
|
||||
}
|
||||
|
||||
function addIndexedName(index: NameIndex, node: GraphNode): void {
|
||||
const qualifiedName = node.properties.qualifiedName;
|
||||
if (typeof qualifiedName === 'string') {
|
||||
index.byQualifiedName.set(
|
||||
qualifiedName,
|
||||
index.byQualifiedName.has(qualifiedName) ? AMBIGUOUS : node.id,
|
||||
);
|
||||
}
|
||||
const simpleName = node.properties.name;
|
||||
index.bySimpleName.set(simpleName, index.bySimpleName.has(simpleName) ? AMBIGUOUS : node.id);
|
||||
}
|
||||
|
||||
function resolveIndexedName(index: NameIndex | undefined, name: string) {
|
||||
if (index === undefined) return undefined;
|
||||
return name.includes('.') ? index.byQualifiedName.get(name) : index.bySimpleName.get(name);
|
||||
}
|
||||
|
||||
function providerCandidates(
|
||||
ids: ReadonlySet<string>,
|
||||
providers: ReadonlyMap<string, DiProviderMatch>,
|
||||
): string[] {
|
||||
const all = [...ids];
|
||||
const recognized = all.filter((id) => providers.has(id));
|
||||
// Recall-first fallback: provider metadata can be incomplete (custom
|
||||
// registration mechanisms and legacy indexes can omit it). Prefer
|
||||
// framework-recognized providers when present, but keep structurally valid
|
||||
// candidates when none are known instead of dropping the injection entirely.
|
||||
return recognized.length > 0 ? recognized : all;
|
||||
}
|
||||
|
||||
export const diPhase: PipelinePhase<DIOutput> = {
|
||||
name: 'di',
|
||||
// Depends on `mro` for ordering: heritage edges (IMPLEMENTS/EXTENDS) must be
|
||||
// fully populated before we resolve interface→implementer fan-out.
|
||||
deps: ['mro'],
|
||||
|
||||
async execute(ctx: PipelineContext): Promise<DIOutput> {
|
||||
|
|
@ -96,174 +96,193 @@ export const diPhase: PipelinePhase<DIOutput> = {
|
|||
stats: { filesProcessed: 0, totalFiles: 0, nodesCreated: ctx.graph.nodeCount },
|
||||
});
|
||||
|
||||
// ── Pass 1: route Property nodes to registered per-language matchers ───
|
||||
// Early-exit optimization: if no registered matcher accepts any Property
|
||||
// node, skip all index construction. This makes the phase a no-op on
|
||||
// repos with no DI-matched fields (no IMPLEMENTS / HAS_PROPERTY scans).
|
||||
const candidates: CandidateField[] = [];
|
||||
|
||||
const candidates: CandidateSite[] = [];
|
||||
const providers = new Map<string, DiProviderMatch>();
|
||||
ctx.graph.forEachNode((node) => {
|
||||
if (node.label !== 'Property') return;
|
||||
const language = node.properties.language;
|
||||
if (language === undefined || !isSupportedLanguage(language)) return;
|
||||
const matcher = DI_MATCHERS.get(language);
|
||||
if (matcher === undefined) return;
|
||||
const match = matcher(node);
|
||||
if (match === null) return;
|
||||
candidates.push({
|
||||
propertyId: node.id,
|
||||
language,
|
||||
elementTypeName: match.elementTypeName,
|
||||
reason: match.reason,
|
||||
});
|
||||
const resolver = DI_RESOLVERS.get(language);
|
||||
if (resolver === undefined) return;
|
||||
|
||||
const provider = resolver.matchProvider(node);
|
||||
if (provider !== null) providers.set(node.id, provider);
|
||||
for (const match of resolver.matchInjectionSites(node)) {
|
||||
candidates.push({ ...match, siteNodeId: node.id, language });
|
||||
}
|
||||
});
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return { injectsEdges: 0, fieldsScanned: 0, ambiguousSkipped: 0 };
|
||||
return {
|
||||
injectsEdges: 0,
|
||||
fieldsScanned: 0,
|
||||
ambiguousSkipped: 0,
|
||||
ambiguousInjections: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Pass 2: build single-pass reverse indexes ─────────────────────────
|
||||
|
||||
// interfaceNodeId → Set<implementerClassId> (reverse of IMPLEMENTS edge)
|
||||
// IMPLEMENTS edges go Class→Interface, so target is the interface.
|
||||
// Keyed by node id — globally unique — so this index needs no language
|
||||
// scoping; only NAME-based lookups (below) do.
|
||||
const interfaceToImplementers = new Map<string, Set<string>>();
|
||||
for (const rel of ctx.graph.iterRelationshipsByType('IMPLEMENTS')) {
|
||||
const implementerId = rel.sourceId; // Class
|
||||
const interfaceId = rel.targetId; // Interface
|
||||
let set = interfaceToImplementers.get(interfaceId);
|
||||
if (set === undefined) {
|
||||
set = new Set();
|
||||
interfaceToImplementers.set(interfaceId, set);
|
||||
const set = interfaceToImplementers.get(rel.targetId) ?? new Set<string>();
|
||||
set.add(rel.sourceId);
|
||||
interfaceToImplementers.set(rel.targetId, set);
|
||||
}
|
||||
|
||||
const memberToClass = new Map<string, string>();
|
||||
for (const relationType of ['HAS_PROPERTY', 'HAS_METHOD'] as const) {
|
||||
for (const rel of ctx.graph.iterRelationshipsByType(relationType)) {
|
||||
memberToClass.set(rel.targetId, rel.sourceId);
|
||||
}
|
||||
set.add(implementerId);
|
||||
}
|
||||
|
||||
// propertyNodeId → consumerClassId (reverse of HAS_PROPERTY edge)
|
||||
// HAS_PROPERTY edges go Class→Property, so target is the property.
|
||||
const propertyToClass = new Map<string, string>();
|
||||
for (const rel of ctx.graph.iterRelationshipsByType('HAS_PROPERTY')) {
|
||||
propertyToClass.set(rel.targetId, rel.sourceId);
|
||||
}
|
||||
|
||||
// language → InterfaceIndex (from Interface-labeled nodes). Scoped per
|
||||
// language so an Interface in one language can never satisfy a candidate
|
||||
// from another. Within a language, a name resolves only while unique —
|
||||
// a second Interface claiming the same simple OR qualified name flips
|
||||
// that entry to AMBIGUOUS and resolution fails closed (never
|
||||
// last-writer-wins).
|
||||
// Index only languages that can resolve: an Interface in a language with
|
||||
// no candidate can never be looked up in Pass 3.
|
||||
const candidateLanguages = new Set<string>(candidates.map((c) => c.language));
|
||||
const interfacesByLanguage = new Map<string, InterfaceIndex>();
|
||||
const candidateLanguages = new Set<string>(candidates.map((candidate) => candidate.language));
|
||||
const interfacesByLanguage = new Map<string, NameIndex>();
|
||||
const classesByLanguage = new Map<string, NameIndex>();
|
||||
const classNodes = new Map<string, GraphNode>();
|
||||
ctx.graph.forEachNode((node) => {
|
||||
if (node.label !== 'Interface') return;
|
||||
if (node.label !== 'Class' && node.label !== 'Interface') return;
|
||||
const language = node.properties.language;
|
||||
if (typeof language !== 'string') return; // no language ⇒ unindexable
|
||||
if (!candidateLanguages.has(language)) return;
|
||||
let index = interfacesByLanguage.get(language);
|
||||
if (index === undefined) {
|
||||
index = { byQualifiedName: new Map(), bySimpleName: new Map() };
|
||||
interfacesByLanguage.set(language, index);
|
||||
}
|
||||
// `qualifiedName` reaches NodeProperties through the extensible index
|
||||
// signature, so narrow it explicitly (no `any`).
|
||||
const qualifiedName = node.properties.qualifiedName;
|
||||
if (typeof qualifiedName === 'string') {
|
||||
index.byQualifiedName.set(
|
||||
qualifiedName,
|
||||
index.byQualifiedName.has(qualifiedName) ? AMBIGUOUS : node.id,
|
||||
);
|
||||
}
|
||||
const simpleName = node.properties.name;
|
||||
index.bySimpleName.set(simpleName, index.bySimpleName.has(simpleName) ? AMBIGUOUS : node.id);
|
||||
if (typeof language !== 'string' || !candidateLanguages.has(language)) return;
|
||||
const indexes = node.label === 'Class' ? classesByLanguage : interfacesByLanguage;
|
||||
const index = indexes.get(language) ?? emptyNameIndex();
|
||||
addIndexedName(index, node);
|
||||
indexes.set(language, index);
|
||||
if (node.label === 'Class') classNodes.set(node.id, node);
|
||||
});
|
||||
|
||||
// ── Pass 3: emit INJECTS edges ────────────────────────────────────────
|
||||
let injectsEdges = 0;
|
||||
let ambiguousSkipped = 0;
|
||||
const ambiguousElementTypes = new Set<string>();
|
||||
const seenEdges = new Set<string>();
|
||||
let ambiguousInjections = 0;
|
||||
const ambiguousTypeNames = new Set<string>();
|
||||
const pending = new Map<string, PendingEdge>();
|
||||
|
||||
const queueEdge = (edge: PendingEdge): void => {
|
||||
if (edge.sourceId === edge.targetId) return;
|
||||
const id = `INJECTS:${edge.sourceId}->${edge.targetId}`;
|
||||
const existing = pending.get(id);
|
||||
if (existing === undefined || edge.confidence > existing.confidence) pending.set(id, edge);
|
||||
};
|
||||
|
||||
for (const candidate of candidates) {
|
||||
// Resolve the consumer Class that owns this Property.
|
||||
const consumerClassId = propertyToClass.get(candidate.propertyId);
|
||||
if (!consumerClassId) continue;
|
||||
const siteNode = ctx.graph.getNode(candidate.siteNodeId);
|
||||
const consumerClassId =
|
||||
siteNode?.label === 'Class' ? siteNode.id : memberToClass.get(candidate.siteNodeId);
|
||||
if (consumerClassId === undefined) continue;
|
||||
|
||||
// Resolve the element type name via the CANDIDATE'S OWN language index
|
||||
// only — a same-named Interface in another language never participates.
|
||||
const index = interfacesByLanguage.get(candidate.language);
|
||||
if (index === undefined) continue;
|
||||
|
||||
// A dotted element type is a qualified name (e.g. `com.a.Shape`) —
|
||||
// exact qualifiedName lookup, unaffected by simple-name ambiguity.
|
||||
// A bare name uses the simple-name index. BOTH lookups fail CLOSED
|
||||
// on their own ambiguity (a qualified name too can be claimed twice —
|
||||
// same package+name across monorepo modules): no edge (never
|
||||
// last-writer-wins), but counted and logged so the skip is
|
||||
// observable. Same-package/import-aware disambiguation is a
|
||||
// deliberate follow-up (plan: Deferred work).
|
||||
let interfaceId: string | undefined;
|
||||
if (candidate.elementTypeName.includes('.')) {
|
||||
const entry = index.byQualifiedName.get(candidate.elementTypeName);
|
||||
if (entry === AMBIGUOUS) {
|
||||
ambiguousSkipped++;
|
||||
ambiguousElementTypes.add(candidate.elementTypeName);
|
||||
continue;
|
||||
}
|
||||
interfaceId = entry;
|
||||
} else {
|
||||
const entry = index.bySimpleName.get(candidate.elementTypeName);
|
||||
if (entry === AMBIGUOUS) {
|
||||
ambiguousSkipped++;
|
||||
ambiguousElementTypes.add(candidate.elementTypeName);
|
||||
continue;
|
||||
}
|
||||
interfaceId = entry;
|
||||
const classEntry = resolveIndexedName(
|
||||
classesByLanguage.get(candidate.language),
|
||||
candidate.targetTypeName,
|
||||
);
|
||||
const interfaceEntry = resolveIndexedName(
|
||||
interfacesByLanguage.get(candidate.language),
|
||||
candidate.targetTypeName,
|
||||
);
|
||||
if (
|
||||
classEntry === AMBIGUOUS ||
|
||||
interfaceEntry === AMBIGUOUS ||
|
||||
(classEntry !== undefined && interfaceEntry !== undefined)
|
||||
) {
|
||||
// A simple/qualified name claimed by both a Class and an Interface is
|
||||
// type-ambiguous too. Fail closed rather than guessing which Java type
|
||||
// the injection site meant; import-aware disambiguation is not
|
||||
// available in this graph-only phase. This intentionally applies to
|
||||
// legacy collection sites too: a Class/Interface collision no longer
|
||||
// fans out through the interface on a simple-name guess.
|
||||
ambiguousSkipped++;
|
||||
ambiguousTypeNames.add(candidate.targetTypeName);
|
||||
continue;
|
||||
}
|
||||
if (interfaceId === undefined) continue;
|
||||
|
||||
// Fan out to every class implementing that interface.
|
||||
const implementers = interfaceToImplementers.get(interfaceId);
|
||||
if (!implementers) continue;
|
||||
const structural = new Set<string>();
|
||||
if (typeof classEntry === 'string') structural.add(classEntry);
|
||||
if (typeof interfaceEntry === 'string') {
|
||||
for (const id of interfaceToImplementers.get(interfaceEntry) ?? []) structural.add(id);
|
||||
}
|
||||
structural.delete(consumerClassId);
|
||||
if (structural.size === 0) continue;
|
||||
|
||||
for (const implId of implementers) {
|
||||
// Skip self-edges: a class never injects its own bean into itself.
|
||||
if (implId === consumerClassId) continue;
|
||||
let viable = providerCandidates(structural, providers);
|
||||
const namedSelection = candidate.namedSelection;
|
||||
if (namedSelection !== undefined) {
|
||||
viable = viable.filter(
|
||||
(id) => providers.get(id)?.names.includes(namedSelection.name) === true,
|
||||
);
|
||||
if (viable.length === 0) continue;
|
||||
}
|
||||
|
||||
// Dedup-safe edge ID: deterministic from (consumer, implementer).
|
||||
const edgeId = `INJECTS:${consumerClassId}->${implId}`;
|
||||
if (seenEdges.has(edgeId)) continue;
|
||||
seenEdges.add(edgeId);
|
||||
if (candidate.cardinality === 'collection') {
|
||||
const confidence = namedSelection === undefined ? 0.8 : 0.9;
|
||||
const suffix = namedSelection === undefined ? '' : `; ${namedSelection.reason}`;
|
||||
for (const targetId of viable) {
|
||||
queueEdge({
|
||||
sourceId: consumerClassId,
|
||||
targetId,
|
||||
confidence,
|
||||
reason: candidate.reason + suffix,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
ctx.graph.addRelationship({
|
||||
id: edgeId,
|
||||
if (viable.length === 1) {
|
||||
const suffix = namedSelection === undefined ? '' : `; ${namedSelection.reason}`;
|
||||
queueEdge({
|
||||
sourceId: consumerClassId,
|
||||
targetId: implId,
|
||||
type: 'INJECTS',
|
||||
confidence: 0.8,
|
||||
// Matcher-supplied reason — names the framework and the annotation
|
||||
// actually found on the field (see di-extractors/).
|
||||
reason: candidate.reason,
|
||||
targetId: viable[0],
|
||||
confidence: namedSelection === undefined ? 0.9 : 0.95,
|
||||
reason: candidate.reason + suffix,
|
||||
});
|
||||
injectsEdges++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const preferred = viable.flatMap((id) => {
|
||||
const reason = providers.get(id)?.preferenceReason;
|
||||
return reason === undefined ? [] : [{ id, reason }];
|
||||
});
|
||||
if (namedSelection === undefined && preferred.length === 1) {
|
||||
const selected = preferred[0];
|
||||
queueEdge({
|
||||
sourceId: consumerClassId,
|
||||
targetId: selected.id,
|
||||
confidence: 0.95,
|
||||
reason: `${candidate.reason}; ${selected.reason}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
ambiguousInjections++;
|
||||
const candidateNames = viable
|
||||
.map((id) => classNodes.get(id)?.properties.name ?? id)
|
||||
.sort()
|
||||
.join(', ');
|
||||
for (const targetId of viable) {
|
||||
queueEdge({
|
||||
sourceId: consumerClassId,
|
||||
targetId,
|
||||
confidence: 0.5,
|
||||
reason: `${candidate.reason}; ambiguous candidates: ${candidateNames}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, edge] of pending) {
|
||||
ctx.graph.addRelationship({ id, type: 'INJECTS', ...edge });
|
||||
}
|
||||
|
||||
if (isDev && ambiguousSkipped > 0) {
|
||||
// One aggregated debug line (not per-candidate spam): duplicate simple
|
||||
// names are NORMAL in large repos, but the skip must stay observable.
|
||||
logger.debug(
|
||||
`🧩 DI: ${ambiguousSkipped} candidate(s) skipped — ambiguous element interface name(s): ${[...ambiguousElementTypes].sort().join(', ')}`,
|
||||
`DI: ${ambiguousSkipped} site(s) skipped because requested type names were ambiguous: ${[...ambiguousTypeNames].sort().join(', ')}`,
|
||||
);
|
||||
}
|
||||
if (isDev && (injectsEdges > 0 || ambiguousSkipped > 0)) {
|
||||
if (isDev && (pending.size > 0 || ambiguousInjections > 0)) {
|
||||
logger.info(
|
||||
`🧩 DI: ${injectsEdges} INJECTS edges from ${candidates.length} injection-annotated collection fields (${ambiguousSkipped} ambiguous skipped)`,
|
||||
`DI: ${pending.size} INJECTS edges from ${candidates.length} injection sites (${ambiguousInjections} ambiguous single-site resolutions)`,
|
||||
);
|
||||
}
|
||||
|
||||
return { injectsEdges, fieldsScanned: candidates.length, ambiguousSkipped };
|
||||
return {
|
||||
injectsEdges: pending.size,
|
||||
fieldsScanned: candidates.length,
|
||||
ambiguousSkipped,
|
||||
ambiguousInjections,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -95,7 +95,9 @@ import {
|
|||
import type { KnowledgeGraph } from '../../graph/types.js';
|
||||
import type { PipelineOptions } from '../pipeline.js';
|
||||
import fs from 'node:fs';
|
||||
import { effectiveRamBytes, memoryAutopilotDisabled } from '../utils/effective-ram.js';
|
||||
import path from 'node:path';
|
||||
import v8 from 'node:v8';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
import { isDev } from '../utils/env.js';
|
||||
|
|
@ -111,6 +113,81 @@ import { isDebugHeapEnabled, logHeapProbe } from '../utils/heap-probe.js';
|
|||
import { logger } from '../../logger.js';
|
||||
// ── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Heap-scale guardrail constants (#2649). Measured on a Linux-kernel analyze:
|
||||
* ~75 graph nodes per PARSEABLE file (~5M nodes / ~65k parseable files;
|
||||
* validated against heap probes at chunks 25/50/75 of 113 — the first
|
||||
* calibration divided by total scanned files and under-projected by ~30%),
|
||||
* main-thread heap per node. C-heavy corpus; other language mixes vary — these
|
||||
* feed a WARNING and an emergency abort, never a hard admission gate, so
|
||||
* estimate error only shifts when the operator hears about the problem, not
|
||||
* whether analyze runs.
|
||||
*
|
||||
* RECALIBRATED for streamed structural emit (#2680), which is on by default for
|
||||
* full rebuilds and holds relationships out of the JS heap. The original 2250
|
||||
* was measured against the object-based graph; an A/B at 400k nodes / 1.08M
|
||||
* edges put streaming at 1.40x smaller (819 MB -> 584 MB), so the corpus-
|
||||
* calibrated figure is divided by that ratio: 2250 / 1.40 ~= 1600. Scaling the
|
||||
* measured constant rather than substituting a synthetic one keeps #2649's
|
||||
* kernel calibration intact and changes only the one thing that actually moved.
|
||||
*
|
||||
* If streaming is disabled (GITNEXUS_STREAM_GRAPH_EMIT=0, or any non-force run)
|
||||
* this UNDER-projects by ~40%, so the preflight warning may stay quiet on a repo
|
||||
* that then struggles. That is the safe direction to be wrong in: the abort
|
||||
* below reads LIVE heap use, not this projection, so it still catches the real
|
||||
* condition — only the early warning is affected.
|
||||
*/
|
||||
const PROJECTED_NODES_PER_FILE = 75;
|
||||
const PROJECTED_HEAP_BYTES_PER_NODE = 1600;
|
||||
/** Warn at scan end when the projection crosses this share of the heap limit. */
|
||||
const PREFLIGHT_WARN_FRACTION = 0.85;
|
||||
/**
|
||||
* Abort the chunk loop when live heap use crosses this share of the limit.
|
||||
* Above ~0.95 V8 enters the ineffective-mark-compact death spiral (2s+ GC
|
||||
* pauses that also falsely idle-timeout healthy workers, #2649); 0.92 leaves
|
||||
* one chunk's worth of headroom to fail with an actionable message instead.
|
||||
* `GITNEXUS_MEMORY=off` declines the abort (proceed-at-own-risk).
|
||||
*/
|
||||
const HEAP_ABORT_FRACTION = 0.92;
|
||||
|
||||
/** Projected main-thread heap need for the parse phase (#2649). */
|
||||
export function projectParseHeapNeedBytes(parseableFileCount: number): number {
|
||||
return parseableFileCount * PROJECTED_NODES_PER_FILE * PROJECTED_HEAP_BYTES_PER_NODE;
|
||||
}
|
||||
|
||||
/** True when the mid-loop heap guard should abort the parse (#2649). */
|
||||
export function shouldAbortForHeapPressure(heapUsedBytes: number, heapLimitBytes: number): boolean {
|
||||
if (memoryAutopilotDisabled()) return false;
|
||||
return heapUsedBytes > heapLimitBytes * HEAP_ABORT_FRACTION;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ONE action a user should take when this repository doesn't fit the
|
||||
* current heap (#2649). Users hitting memory limits are already frustrated —
|
||||
* a menu of env knobs at that moment is noise. Branch on whether the machine
|
||||
* itself has more memory to give: if this process's limit sits well below
|
||||
* what the RAM-aware auto-sizer would grant (an inherited NODE_OPTIONS pin or
|
||||
* explicit flag), the fix is to drop the pin — gitnexus sizes itself.
|
||||
* Otherwise the machine is the ceiling and only scope or hardware helps.
|
||||
* Escape hatches (GITNEXUS_MEMORY etc.) stay in the README env table.
|
||||
*/
|
||||
export function heapPressureRemedy(heapLimitBytes: number): string {
|
||||
// Effective RAM honors a real cgroup limit — raw os.totalmem() told users
|
||||
// inside an 8GB-limited container on a 64GB host that "this machine has
|
||||
// more memory available", an advice loop with no exit (#2649 review).
|
||||
const autoCapBytes = effectiveRamBytes() * 0.75;
|
||||
if (heapLimitBytes < autoCapBytes * 0.9) {
|
||||
return (
|
||||
`This machine has more memory available: re-run without the --max-old-space-size ` +
|
||||
`pin (NODE_OPTIONS or node flag) — gitnexus sizes its heap to the machine automatically.`
|
||||
);
|
||||
}
|
||||
return (
|
||||
`This machine is at its memory ceiling: exclude generated or vendored directories ` +
|
||||
`via .gitnexusignore, or analyze on a machine with more memory.`
|
||||
);
|
||||
}
|
||||
|
||||
/** Max bytes of source content to load per parse chunk.
|
||||
*
|
||||
* Memory bound for the worker pool dispatch + a granularity knob for
|
||||
|
|
@ -516,6 +593,22 @@ export async function runChunkedParseAndResolve(
|
|||
MIN_SUB_BATCH_BYTES,
|
||||
Math.ceil(chunkByteBudget / (effectivePoolSize * TARGET_JOBS_PER_WORKER)),
|
||||
);
|
||||
// Heap-scale guardrails (#2649), measured on a Linux-kernel analyze
|
||||
// (94,773 files): ~55 graph nodes per parseable file and ~2.2KB of
|
||||
// main-thread heap per node, linear across 113 chunks (see
|
||||
// docs/plans/2026-07-23-gitnexus-plan-large-repo-analyze-oom.md §2).
|
||||
// Estimates, not contracts — used only to warn early (preflight) and to
|
||||
// convert a certain multi-minute GC death spiral into an immediate
|
||||
// actionable error (mid-loop guard).
|
||||
const projectedHeapNeedBytes = projectParseHeapNeedBytes(parseableScanned.length);
|
||||
const heapLimitBytes = v8.getHeapStatistics().heap_size_limit;
|
||||
if (projectedHeapNeedBytes > heapLimitBytes * PREFLIGHT_WARN_FRACTION) {
|
||||
logger.warn(
|
||||
`Large repository: analyzing ${parseableScanned.length} files needs roughly ${Math.round(projectedHeapNeedBytes / 1024 / 1024 / 1024)}GB of memory, ` +
|
||||
`but Node is limited to ${Math.round(heapLimitBytes / 1024 / 1024 / 1024)}GB — analyze may stop early. ${heapPressureRemedy(heapLimitBytes)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const chunks: string[][] = [];
|
||||
let currentChunk: string[] = [];
|
||||
let currentBytes = 0;
|
||||
|
|
@ -869,6 +962,18 @@ export async function runChunkedParseAndResolve(
|
|||
`nodes=${graph.nodeCount} parsedFiles=${allParsedFiles.length}`,
|
||||
);
|
||||
}
|
||||
// #2649 mid-loop heap guard: fail actionably BEFORE V8 enters the
|
||||
// ineffective-mark-compact death spiral (which also falsely times out
|
||||
// healthy workers). The pool is torn down by this function's finally.
|
||||
const heapUsedNow = process.memoryUsage().heapUsed;
|
||||
const heapLimitNow = v8.getHeapStatistics().heap_size_limit;
|
||||
if (shouldAbortForHeapPressure(heapUsedNow, heapLimitNow)) {
|
||||
throw new Error(
|
||||
`Analyze stopped before running out of memory: ${Math.round(heapUsedNow / 1024 / 1024)}MB of the ` +
|
||||
`${Math.round(heapLimitNow / 1024 / 1024)}MB Node heap in use at parse chunk ${chunkIdx + 1}/${numChunks} (#2649). ` +
|
||||
heapPressureRemedy(heapLimitNow),
|
||||
);
|
||||
}
|
||||
const chunkPaths = chunks[chunkIdx];
|
||||
// Start wall-clock for the per-chunk throughput log emitted at end
|
||||
// of this iteration. The gate is computed once above; here we just
|
||||
|
|
|
|||
|
|
@ -61,6 +61,8 @@ export interface ParseOutput {
|
|||
model: MutableSemanticModel;
|
||||
/** Pass-through: all file paths for downstream phases. */
|
||||
readonly allPaths: readonly string[];
|
||||
/** Pass-through: shared `allPathSet` from structure (built once, not per-phase). */
|
||||
readonly allPathSet: ReadonlySet<string>;
|
||||
/** Pass-through: total file count for progress reporting. */
|
||||
totalFiles: number;
|
||||
/**
|
||||
|
|
@ -89,6 +91,12 @@ export const parsePhase: PipelinePhase<ParseOutput> = {
|
|||
ctx: PipelineContext,
|
||||
deps: ReadonlyMap<string, PhaseResult<unknown>>,
|
||||
): Promise<ParseOutput> {
|
||||
// Begin streamed structural emit (#2680), if enabled. Deliberately here and
|
||||
// not at graph construction: the pre-parse phases are not all write-only —
|
||||
// `mapCobolToGraph` scans CALLS edges and removes the unresolved ones — and
|
||||
// nothing before parse produces bulk edge volume anyway.
|
||||
ctx.graphEmit?.beginStreaming();
|
||||
|
||||
const structure = getPhaseOutput<StructureOutput>(deps, 'structure');
|
||||
const { totalFiles } = structure;
|
||||
|
||||
|
|
@ -101,6 +109,8 @@ export const parsePhase: PipelinePhase<ParseOutput> = {
|
|||
const allPaths = ingested.size
|
||||
? structure.allPaths.filter((p) => !ingested.has(p))
|
||||
: structure.allPaths;
|
||||
// Keep the O(1) lookup set consistent with the filtered path list.
|
||||
const allPathSet = ingested.size ? new Set(allPaths) : structure.allPathSet;
|
||||
|
||||
const result = await runChunkedParseAndResolve(
|
||||
ctx.graph,
|
||||
|
|
@ -116,6 +126,7 @@ export const parsePhase: PipelinePhase<ParseOutput> = {
|
|||
return {
|
||||
...result,
|
||||
allPaths,
|
||||
allPathSet,
|
||||
totalFiles,
|
||||
};
|
||||
},
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
* - Each phase is independently testable with mocked inputs
|
||||
*/
|
||||
|
||||
import type { GraphEmitControl } from '../../lbug/graph-emit-sink.js';
|
||||
import type { KnowledgeGraph } from '../../graph/types.js';
|
||||
import type { PipelineProgress } from 'gitnexus-shared';
|
||||
import type { PipelineOptions } from '../pipeline.js';
|
||||
|
|
@ -32,6 +33,12 @@ export interface PipelineContext {
|
|||
readonly options?: PipelineOptions;
|
||||
/** Pipeline start timestamp (for elapsed-time logging). */
|
||||
readonly pipelineStart: number;
|
||||
/**
|
||||
* Streamed structural emit (#2680), present only when `streamGraphEmit` is on.
|
||||
* `parse` calls `beginStreaming()` at its start; `pruneLocalSymbols` consults
|
||||
* `hasStreamedSemanticEdge()`. Absent ⇒ everything stays in the graph.
|
||||
*/
|
||||
readonly graphEmit?: GraphEmitControl;
|
||||
}
|
||||
|
||||
// ── Phase result wrapper ───────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
*/
|
||||
|
||||
import { createKnowledgeGraph } from '../graph/graph.js';
|
||||
import { GraphEmitSink, type GraphEmitManifest } from '../lbug/graph-emit-sink.js';
|
||||
import { type PipelineProgress } from 'gitnexus-shared';
|
||||
import { PipelineResult } from '../../types/pipeline.js';
|
||||
import {
|
||||
|
|
@ -146,6 +147,22 @@ export interface PipelineOptions<
|
|||
* whole-graph emit.
|
||||
*/
|
||||
streamPdgEmit?: boolean;
|
||||
/**
|
||||
* Streamed structural graph emit (#2680). When true, relationships that no
|
||||
* mid-pipeline phase reads back (CALLS, IMPORTS, ACCESSES, CONTAINS, ...) are
|
||||
* streamed to CSV-on-disk from the parse boundary onward instead of being
|
||||
* retained in the in-memory graph — measured ~2.9x reduction of graph heap.
|
||||
*
|
||||
* NOT free: the `communities`, `processes`, `taintSummaries` and
|
||||
* `callSummaries` phases all consume the whole CALLS graph and are disabled
|
||||
* under this flag. The caller (`run-analyze`) gates it to full rebuilds.
|
||||
* Requires `graphEmitCsvDir`.
|
||||
*/
|
||||
streamGraphEmit?: boolean;
|
||||
/** Directory for the streamed structural CSVs. Required when
|
||||
* `streamGraphEmit` is on; supplied by the caller, which owns storage-path
|
||||
* resolution (and its native-safe relocation). */
|
||||
graphEmitCsvDir?: string;
|
||||
/** Streamed PDG-emit write buffer (rows) when `streamPdgEmit` is on (#2202).
|
||||
* `undefined` ⇒ `DEFAULT_PDG_EMIT_CHUNK_ROWS`. Memory-only; does not affect
|
||||
* emitted bytes. */
|
||||
|
|
@ -309,15 +326,46 @@ export const runPipelineFromRepo = async <
|
|||
const graph = createKnowledgeGraph();
|
||||
const pipelineStart = Date.now();
|
||||
|
||||
// Streamed structural emit (#2680). The sink is a write-routing façade over
|
||||
// `graph`; it streams nothing until `beginStreaming()` fires at the parse
|
||||
// boundary.
|
||||
//
|
||||
// A missing `graphEmitCsvDir` is a caller bug, not a reason to quietly skip
|
||||
// streaming: this is on by default, so a programmatic host that builds its own
|
||||
// `PipelineOptions` (eval-server, the MCP daemon, a test) would otherwise ask
|
||||
// for streaming, silently not get it, and still see a successful run. Fail
|
||||
// loudly instead — the whole point of the surrounding work is that a degraded
|
||||
// outcome must never look like a clean one.
|
||||
let graphEmitSink: GraphEmitSink | undefined;
|
||||
if (options?.streamGraphEmit === true) {
|
||||
if (options.graphEmitCsvDir === undefined) {
|
||||
throw new Error(
|
||||
'streamGraphEmit was requested but graphEmitCsvDir is missing. The caller owns ' +
|
||||
'storage-path resolution (see resolveNativeSafeStorageDir in run-analyze.ts); ' +
|
||||
'pass the directory, or leave streamGraphEmit unset to run without streaming.',
|
||||
);
|
||||
}
|
||||
graphEmitSink = new GraphEmitSink(graph, options.graphEmitCsvDir);
|
||||
}
|
||||
|
||||
const phases = buildPhaseList(options);
|
||||
|
||||
const results = await runPipeline(phases, {
|
||||
repoPath,
|
||||
graph,
|
||||
onProgress,
|
||||
options,
|
||||
pipelineStart,
|
||||
});
|
||||
let graphEmitManifest: GraphEmitManifest | undefined;
|
||||
let results;
|
||||
try {
|
||||
results = await runPipeline(phases, {
|
||||
repoPath,
|
||||
graph: graphEmitSink ?? graph,
|
||||
onProgress,
|
||||
options,
|
||||
pipelineStart,
|
||||
graphEmit: graphEmitSink,
|
||||
});
|
||||
graphEmitManifest = graphEmitSink?.finalize();
|
||||
} finally {
|
||||
// Release per-pair fds when the pipeline threw before finalize ran.
|
||||
graphEmitSink?.close();
|
||||
}
|
||||
|
||||
// Extract final results for the PipelineResult contract
|
||||
const { totalFiles, usedWorkerPool } = getPhaseOutput<{
|
||||
|
|
@ -336,7 +384,12 @@ export const runPipelineFromRepo = async <
|
|||
// Streamed PDG-emit manifest (#2202): present only when streaming was on.
|
||||
const pdgEmitManifest = scopeResolutionOutput.pdgEmitManifest;
|
||||
|
||||
if (!options?.skipGraphPhases) {
|
||||
// Presence check, not `!skipGraphPhases`: phases can now be filtered out by
|
||||
// any `enabledWhen` predicate (streamGraphEmit disables communities/processes
|
||||
// too), and `getPhaseOutput` THROWS on a phase that was never resolved. Keying
|
||||
// off the options flag alone made every filtered-out combination crash here
|
||||
// rather than return undefined results.
|
||||
if (results.has('communities') && results.has('processes')) {
|
||||
communityResult = getPhaseOutput<CommunitiesOutput>(results, 'communities').communityResult;
|
||||
processResult = getPhaseOutput<ProcessesOutput>(results, 'processes').processResult;
|
||||
}
|
||||
|
|
@ -356,9 +409,16 @@ export const runPipelineFromRepo = async <
|
|||
});
|
||||
|
||||
return {
|
||||
// The RAW graph, deliberately — NOT `graphEmitSink`. Phases above received
|
||||
// the sink so their reads are complete, but `loadGraphToLbug` feeds this to
|
||||
// `streamAllCSVsToDisk`, and the sink's complete iterator would then emit
|
||||
// every streamed edge a SECOND time on top of the per-pair CSVs the sink
|
||||
// already wrote and the manifest already COPYs. Returning the sink here
|
||||
// silently doubles every streamed relationship in the persisted graph.
|
||||
graph,
|
||||
repoPath,
|
||||
totalFileCount: totalFiles,
|
||||
graphEmitManifest,
|
||||
communityResult,
|
||||
processResult,
|
||||
resolutionOutcomes,
|
||||
|
|
|
|||
|
|
@ -248,12 +248,12 @@ const buildCallsAdjacency = (
|
|||
else bucket.push(value);
|
||||
};
|
||||
|
||||
for (const rel of graph.iterRelationships()) {
|
||||
if (rel.type === 'CALLS' && rel.confidence >= MIN_TRACE_CONFIDENCE) {
|
||||
push(forward, rel.sourceId, rel.targetId);
|
||||
push(reverse, rel.targetId, rel.sourceId);
|
||||
}
|
||||
}
|
||||
// Field-wise scan (#2680) — whole-graph walk, four fields, no object needed.
|
||||
graph.forEachRelationshipFields((sourceId, targetId, type, confidence) => {
|
||||
if (type !== 'CALLS' || confidence < MIN_TRACE_CONFIDENCE) return;
|
||||
push(forward, sourceId, targetId);
|
||||
push(reverse, targetId, sourceId);
|
||||
});
|
||||
|
||||
return { forward, reverse };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -705,6 +705,7 @@ function parseJsonStringArrayCapture(
|
|||
|
||||
function deriveDeclarationName(match: CaptureMatch, def: SymbolDefinition): string | undefined {
|
||||
const nameCap =
|
||||
match['@declaration.binding-name'] ??
|
||||
match['@declaration.name'] ??
|
||||
match[
|
||||
Object.keys(match).find((k) => k.startsWith('@declaration.') && k.endsWith('.name')) ?? ''
|
||||
|
|
|
|||
|
|
@ -30,6 +30,10 @@ interface Target {
|
|||
readonly def: SymbolDefinition;
|
||||
}
|
||||
|
||||
// Shared miss results for the fixpoint's read paths — callers only iterate.
|
||||
const EMPTY_TARGETS: ReadonlyMap<string, Target> = new Map();
|
||||
const EMPTY_CELLS: ReadonlySet<string> = new Set();
|
||||
|
||||
interface FileFact {
|
||||
readonly filePath: string;
|
||||
readonly site: CallableFlowSite;
|
||||
|
|
@ -40,13 +44,22 @@ interface FileInvoke {
|
|||
readonly site: CallableFlowInvokeSite;
|
||||
}
|
||||
|
||||
export interface CallableValueFlowWarning {
|
||||
interface RawCallableValueFlowWarning {
|
||||
readonly language: string;
|
||||
readonly context: string;
|
||||
readonly candidateCount: number;
|
||||
readonly cap: number;
|
||||
}
|
||||
|
||||
export interface CallableValueFlowWarning extends RawCallableValueFlowWarning {
|
||||
/** Number of internal cells represented by this aggregate warning. */
|
||||
readonly occurrences: number;
|
||||
/** Number of source contexts represented by this aggregate warning. */
|
||||
readonly distinctContexts: number;
|
||||
/** Bounded diagnostic sample; stdout receives one aggregate, not every site. */
|
||||
readonly contextSamples: readonly string[];
|
||||
}
|
||||
|
||||
export interface CallableValueFlowResult {
|
||||
readonly emitted: number;
|
||||
readonly resolvedInvokes: number;
|
||||
|
|
@ -55,6 +68,46 @@ export interface CallableValueFlowResult {
|
|||
readonly iterations: number;
|
||||
}
|
||||
|
||||
/** Collapse internal-cell overflows to one bounded warning per language/cap. */
|
||||
export function aggregateCallableValueFlowWarnings(
|
||||
warnings: Iterable<RawCallableValueFlowWarning>,
|
||||
): CallableValueFlowWarning[] {
|
||||
const grouped = new Map<
|
||||
string,
|
||||
RawCallableValueFlowWarning & {
|
||||
// Re-declared mutable so occurrences can raise it (the raw field is readonly).
|
||||
candidateCount: number;
|
||||
occurrences: number;
|
||||
allContexts: Set<string>;
|
||||
contextSamples: string[];
|
||||
}
|
||||
>();
|
||||
for (const warning of warnings) {
|
||||
const key = `${warning.language}\0${warning.cap}`;
|
||||
const current = grouped.get(key);
|
||||
if (!current) {
|
||||
grouped.set(key, {
|
||||
...warning,
|
||||
occurrences: 1,
|
||||
allContexts: new Set([warning.context]),
|
||||
contextSamples: [warning.context],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
current.candidateCount = Math.max(current.candidateCount, warning.candidateCount);
|
||||
current.occurrences++;
|
||||
current.allContexts.add(warning.context);
|
||||
if (current.contextSamples.length < 5 && !current.contextSamples.includes(warning.context)) {
|
||||
current.contextSamples.push(warning.context);
|
||||
}
|
||||
}
|
||||
return [...grouped.values()].map(({ allContexts, ...warning }) => ({
|
||||
...warning,
|
||||
distinctContexts: allContexts.size,
|
||||
}));
|
||||
}
|
||||
|
||||
export interface EmitCallableValueFlowInput {
|
||||
readonly graph: KnowledgeGraph;
|
||||
readonly scopes: ScopeResolutionIndexes;
|
||||
|
|
@ -66,6 +119,12 @@ export interface EmitCallableValueFlowInput {
|
|||
readonly isCallableValueTarget?: (def: SymbolDefinition) => boolean;
|
||||
readonly hasFileLocalCallableLinkage?: (def: SymbolDefinition) => boolean;
|
||||
readonly onWarn?: (warning: CallableValueFlowWarning) => void;
|
||||
/**
|
||||
* Precomputed {@link collectDeferredIndirectSites} result for the same
|
||||
* files/scopes. The orchestrator already needs it to build skip sets;
|
||||
* threading it here avoids a second whole-repo scan. Recomputed when absent.
|
||||
*/
|
||||
readonly canonicalInvokeKeys?: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
/** Position key shared with the existing free/reference skip-set contract. */
|
||||
|
|
@ -76,6 +135,34 @@ export function callableFlowSiteKey(
|
|||
return `${filePath}:${range.startLine}:${range.startCol}`;
|
||||
}
|
||||
|
||||
function warningContext(filePath: string, site: CallableFlowSite): string {
|
||||
let range: { readonly startLine: number; readonly startCol: number };
|
||||
switch (site.kind) {
|
||||
case 'seed':
|
||||
case 'copy':
|
||||
case 'alias':
|
||||
case 'address':
|
||||
case 'load':
|
||||
range = site.destination.atRange;
|
||||
break;
|
||||
case 'store':
|
||||
range = site.pointer.atRange;
|
||||
break;
|
||||
case 'formal':
|
||||
range = site.ownerRange;
|
||||
break;
|
||||
case 'argument':
|
||||
case 'invoke':
|
||||
range = site.callSite;
|
||||
break;
|
||||
default: {
|
||||
const exhaustive: never = site;
|
||||
throw new Error(`Unhandled callable-flow site kind: ${String(exhaustive)}`);
|
||||
}
|
||||
}
|
||||
return `${site.kind}:${callableFlowSiteKey(filePath, range)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return only invoke sites that join to a canonical call ReferenceSite.
|
||||
* Malformed/stale facts never suppress ordinary resolution.
|
||||
|
|
@ -140,7 +227,8 @@ function flowCellOperand(site: CallableFlowSite): CallableFlowOperand | undefine
|
|||
export function emitCallableValueFlow(input: EmitCallableValueFlowInput): CallableValueFlowResult {
|
||||
const facts: FileFact[] = [];
|
||||
const invokes: FileInvoke[] = [];
|
||||
const canonicalInvokeKeys = collectDeferredIndirectSites(input.parsedFiles, input.scopes);
|
||||
const canonicalInvokeKeys =
|
||||
input.canonicalInvokeKeys ?? collectDeferredIndirectSites(input.parsedFiles, input.scopes);
|
||||
let unmatchedInvokes = 0;
|
||||
for (const parsed of input.parsedFiles) {
|
||||
for (const site of parsed.callableFlowSites ?? []) {
|
||||
|
|
@ -161,7 +249,7 @@ export function emitCallableValueFlow(input: EmitCallableValueFlowInput): Callab
|
|||
const addressesByBinding = new Map<string, Set<string>>();
|
||||
const overflowedTargets = new Set<string>();
|
||||
const overflowedAddresses = new Set<string>();
|
||||
const overflowWarnings = new Map<string, CallableValueFlowWarning>();
|
||||
const overflowWarnings = new Map<string, RawCallableValueFlowWarning>();
|
||||
const rawGraphTargets = buildGraphTargetIndex(
|
||||
input.scopes,
|
||||
input.nodeLookup,
|
||||
|
|
@ -311,7 +399,7 @@ export function emitCallableValueFlow(input: EmitCallableValueFlowInput): Callab
|
|||
): { readonly targets: ReadonlyMap<string, Target>; readonly overflow: boolean } => {
|
||||
watch('target', key);
|
||||
return {
|
||||
targets: targetsByBinding.get(key) ?? new Map(),
|
||||
targets: targetsByBinding.get(key) ?? EMPTY_TARGETS,
|
||||
overflow: overflowedTargets.has(key),
|
||||
};
|
||||
};
|
||||
|
|
@ -321,7 +409,7 @@ export function emitCallableValueFlow(input: EmitCallableValueFlowInput): Callab
|
|||
): { readonly cells: ReadonlySet<string>; readonly overflow: boolean } => {
|
||||
watch('address', key);
|
||||
return {
|
||||
cells: addressesByBinding.get(key) ?? new Set(),
|
||||
cells: addressesByBinding.get(key) ?? EMPTY_CELLS,
|
||||
overflow: overflowedAddresses.has(key),
|
||||
};
|
||||
};
|
||||
|
|
@ -476,10 +564,10 @@ export function emitCallableValueFlow(input: EmitCallableValueFlowInput): Callab
|
|||
|
||||
for (const fact of facts) {
|
||||
const site = fact.site;
|
||||
const context = `${fact.site.kind}:${fact.filePath}`;
|
||||
switch (site.kind) {
|
||||
case 'copy':
|
||||
case 'alias': {
|
||||
const context = warningContext(fact.filePath, site);
|
||||
addWorkItem(() => {
|
||||
const source = bindingKey(fact.filePath, site.source);
|
||||
const destination = bindingKey(fact.filePath, site.destination);
|
||||
|
|
@ -493,6 +581,7 @@ export function emitCallableValueFlow(input: EmitCallableValueFlowInput): Callab
|
|||
break;
|
||||
}
|
||||
case 'load': {
|
||||
const context = warningContext(fact.filePath, site);
|
||||
addWorkItem(() => {
|
||||
const destination = bindingKey(fact.filePath, site.destination);
|
||||
const reached = reachedCells(fact.filePath, site.pointer);
|
||||
|
|
@ -509,6 +598,7 @@ export function emitCallableValueFlow(input: EmitCallableValueFlowInput): Callab
|
|||
break;
|
||||
}
|
||||
case 'store': {
|
||||
const context = warningContext(fact.filePath, site);
|
||||
addWorkItem(() => {
|
||||
const sourceTargets = operandTargets(fact.filePath, site.source);
|
||||
const reached = reachedCells(fact.filePath, site.pointer);
|
||||
|
|
@ -585,7 +675,11 @@ export function emitCallableValueFlow(input: EmitCallableValueFlowInput): Callab
|
|||
targetIds.add(id);
|
||||
}
|
||||
for (const id of dynamicCallees.get(callKey)?.keys() ?? []) targetIds.add(id);
|
||||
let hasIndexedFormal = [...targetIds].some((id) => indexedFormals(id).length > 0);
|
||||
const hasAnyIndexedFormal = (): boolean => {
|
||||
for (const id of targetIds) if (indexedFormals(id).length > 0) return true;
|
||||
return false;
|
||||
};
|
||||
let hasIndexedFormal = hasAnyIndexedFormal();
|
||||
if (!hasIndexedFormal && site.directCalleeName !== undefined) {
|
||||
for (const target of resolveSeedCandidates(
|
||||
fact.filePath,
|
||||
|
|
@ -601,7 +695,7 @@ export function emitCallableValueFlow(input: EmitCallableValueFlowInput): Callab
|
|||
)) {
|
||||
targetIds.add(target.id);
|
||||
}
|
||||
hasIndexedFormal = [...targetIds].some((id) => indexedFormals(id).length > 0);
|
||||
hasIndexedFormal = hasAnyIndexedFormal();
|
||||
}
|
||||
const history = dynamicTargetHistory.get(callKey);
|
||||
const callOverflow =
|
||||
|
|
@ -684,7 +778,12 @@ export function emitCallableValueFlow(input: EmitCallableValueFlowInput): Callab
|
|||
);
|
||||
}
|
||||
|
||||
for (const warning of overflowWarnings.values()) input.onWarn?.(warning);
|
||||
// A large generated bundle can create thousands of distinct binding cells
|
||||
// at the same source site. Preserve the causal evidence while emitting one
|
||||
// structured warning per site instead of one line per internal cell.
|
||||
for (const warning of aggregateCallableValueFlowWarnings(overflowWarnings.values())) {
|
||||
input.onWarn?.(warning);
|
||||
}
|
||||
|
||||
// No partial graph output when a hostile/corrupt fact graph exhausts the
|
||||
// bounded work budget. The caller receives a warning; NOTE this is not
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
*/
|
||||
|
||||
import type {
|
||||
DefId,
|
||||
ParameterTypeClass,
|
||||
ParsedFile,
|
||||
Reference,
|
||||
|
|
@ -37,11 +38,13 @@ import type {
|
|||
import { resolveCallerGraphId, resolveDefGraphId } from '../graph-bridge/ids.js';
|
||||
import type { CalleeIdSink } from '../graph-bridge/callee-id-sink.js';
|
||||
import {
|
||||
findAllCallableBindingCandidatesInScope,
|
||||
findAllCallableBindingsInScope,
|
||||
findCallableBindingInScope,
|
||||
findCallableBindingsAndAdlBlocker,
|
||||
findEnclosingClassDef,
|
||||
resolveInheritanceBaseInScope,
|
||||
type CallableBindingCandidate,
|
||||
} from '../scope/walkers.js';
|
||||
import {
|
||||
isOverloadAmbiguousAfterNormalization,
|
||||
|
|
@ -131,8 +134,46 @@ export function emitFreeCallFallback(
|
|||
options.isCallableVisibleFromCaller === undefined
|
||||
? new Map<string, readonly SymbolDefinition[]>()
|
||||
: undefined;
|
||||
const enclosingInstanceOwnerByScope =
|
||||
options.freeCallsRequireInstanceOwnership === true
|
||||
? new Map<ScopeId, SymbolDefinition | null>()
|
||||
: undefined;
|
||||
const reachableInstanceOwnersByOwner =
|
||||
options.freeCallsRequireInstanceOwnership === true
|
||||
? new Map<string, ReadonlySet<string>>()
|
||||
: undefined;
|
||||
const instanceOwnerKey = (ownerId: string): string => {
|
||||
const owner = scopes.defs.get(ownerId as DefId);
|
||||
const qualifiedName = owner?.qualifiedName;
|
||||
if (qualifiedName === undefined || qualifiedName === '') return ownerId;
|
||||
const namespacePrefix = owner.namespacePrefix ?? '';
|
||||
return `${namespacePrefix.length}:${namespacePrefix}:${qualifiedName}`;
|
||||
};
|
||||
const isReachableInstanceOwner = (scopeId: ScopeId, ownerId: string): boolean => {
|
||||
let enclosing = enclosingInstanceOwnerByScope?.get(scopeId);
|
||||
if (enclosing === undefined) {
|
||||
enclosing = findEnclosingClassDef(scopeId, scopes) ?? null;
|
||||
enclosingInstanceOwnerByScope?.set(scopeId, enclosing);
|
||||
}
|
||||
if (enclosing === null) return false;
|
||||
|
||||
let owners = reachableInstanceOwnersByOwner?.get(enclosing.nodeId);
|
||||
if (owners === undefined) {
|
||||
const mutableOwners = new Set<string>([instanceOwnerKey(enclosing.nodeId)]);
|
||||
for (const inheritedOwnerId of scopes.methodDispatch.mroFor(enclosing.nodeId)) {
|
||||
mutableOwners.add(instanceOwnerKey(inheritedOwnerId));
|
||||
}
|
||||
owners = mutableOwners;
|
||||
reachableInstanceOwnersByOwner?.set(enclosing.nodeId, owners);
|
||||
}
|
||||
return owners.has(instanceOwnerKey(ownerId));
|
||||
};
|
||||
|
||||
for (const parsed of parsedFiles) {
|
||||
const bindingCandidatesByScope =
|
||||
options.freeCallsRequireInstanceOwnership === true
|
||||
? new Map<ScopeId, Map<string, readonly CallableBindingCandidate[]>>()
|
||||
: undefined;
|
||||
for (const site of parsed.referenceSites) {
|
||||
if (site.kind !== 'call') continue;
|
||||
if (site.explicitReceiver !== undefined) continue;
|
||||
|
|
@ -202,11 +243,61 @@ export function emitFreeCallFallback(
|
|||
// (local shadows import). When a conversion-rank function is
|
||||
// available AND the binding scope contains multiple overloads,
|
||||
// refine with narrowOverloadCandidates (#1578).
|
||||
fnDef = findCallableBindingInScope(site.inScope, site.name, scopes);
|
||||
let bindingCandidates: readonly CallableBindingCandidate[] | undefined;
|
||||
if (bindingCandidatesByScope !== undefined) {
|
||||
let byName = bindingCandidatesByScope.get(site.inScope);
|
||||
if (byName === undefined) {
|
||||
byName = new Map();
|
||||
bindingCandidatesByScope.set(site.inScope, byName);
|
||||
}
|
||||
bindingCandidates = byName.get(site.name);
|
||||
if (bindingCandidates === undefined) {
|
||||
bindingCandidates = findAllCallableBindingCandidatesInScope(
|
||||
site.inScope,
|
||||
site.name,
|
||||
scopes,
|
||||
);
|
||||
byName.set(site.name, bindingCandidates);
|
||||
}
|
||||
}
|
||||
let eligibleBindingCandidates: readonly CallableBindingCandidate[] | undefined;
|
||||
if (bindingCandidates === undefined) {
|
||||
fnDef = findCallableBindingInScope(site.inScope, site.name, scopes);
|
||||
} else {
|
||||
eligibleBindingCandidates = bindingCandidates.filter((candidate) => {
|
||||
const def = candidate.def;
|
||||
if (
|
||||
def.type !== 'Method' ||
|
||||
def.ownerId === undefined ||
|
||||
def.filePath !== parsed.filePath
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const ownerReachable = isReachableInstanceOwner(site.inScope, def.ownerId);
|
||||
const staticallyImported = candidate.bindings.some(
|
||||
(binding) => binding.visibility === 'static-member-import',
|
||||
);
|
||||
return ownerReachable || staticallyImported;
|
||||
});
|
||||
fnDef = eligibleBindingCandidates[0]?.def;
|
||||
if (fnDef === undefined && bindingCandidates.length > 0) {
|
||||
recordSuppressedOutcome(options.recordResolutionOutcome, {
|
||||
phase: 'free-call-fallback',
|
||||
filePath: parsed.filePath,
|
||||
name: site.name,
|
||||
range: site.atRange,
|
||||
reason: 'free-call-instance-ownership',
|
||||
candidates: bindingCandidates.map((candidate) => candidate.def),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (
|
||||
fnDef !== undefined &&
|
||||
options.isBuiltInName?.(site.name) === true &&
|
||||
fnDef.filePath === parsed.filePath &&
|
||||
eligibleBindingCandidates?.some((candidate) =>
|
||||
candidate.bindings.some((binding) => binding.visibility === 'static-member-import'),
|
||||
) !== true &&
|
||||
!hasGenuineLexicalBinding(site.inScope, site.name, scopes)
|
||||
) {
|
||||
// A platform/language built-in (e.g. `fetch`, `setTimeout`)
|
||||
|
|
@ -234,48 +325,14 @@ export function emitFreeCallFallback(
|
|||
// stopped resolving (verified via a scratch probe fixture).
|
||||
fnDef = undefined;
|
||||
}
|
||||
// Instance-ownership gate (#2550). Placement matters: after the
|
||||
// scope-chain lookup, BEFORE overload narrowing -- a suppressed
|
||||
// candidate must not participate in overload selection. The
|
||||
// legitimate same-class bare call already resolved earlier via
|
||||
// `pickImplicitThisOverload`; an inherited bare call passes the
|
||||
// MRO arm here; what remains is the finalize-bucket leak (an
|
||||
// unrelated same-file method matched by bare name).
|
||||
//
|
||||
// Same-file only (mirrors the #2545 guard's load-bearing
|
||||
// condition): the `materializeBindings` bucket is per-file, so
|
||||
// the leak is ALWAYS same-file. A cross-file Method match here
|
||||
// came through a genuine import channel -- e.g. the arity-
|
||||
// narrowing parity fixtures resolve a bare `writeAudit(u)` to
|
||||
// an imported class's method, which must keep working
|
||||
// (suppressing it broke `java.test.ts`'s arity-filtering suite,
|
||||
// verified empirically).
|
||||
if (
|
||||
fnDef !== undefined &&
|
||||
options.freeCallsRequireInstanceOwnership === true &&
|
||||
fnDef.type === 'Method' &&
|
||||
fnDef.ownerId !== undefined &&
|
||||
fnDef.filePath === parsed.filePath
|
||||
(options.conversionRankFn !== undefined || bindingCandidates !== undefined)
|
||||
) {
|
||||
const enclosing = findEnclosingClassDef(site.inScope, scopes);
|
||||
const ownerReachable =
|
||||
enclosing !== undefined &&
|
||||
(enclosing.nodeId === fnDef.ownerId ||
|
||||
scopes.methodDispatch.mroFor(enclosing.nodeId).includes(fnDef.ownerId));
|
||||
if (!ownerReachable) {
|
||||
recordSuppressedOutcome(options.recordResolutionOutcome, {
|
||||
phase: 'free-call-fallback',
|
||||
filePath: parsed.filePath,
|
||||
name: site.name,
|
||||
range: site.atRange,
|
||||
reason: 'free-call-instance-ownership',
|
||||
candidates: [fnDef],
|
||||
});
|
||||
fnDef = undefined;
|
||||
}
|
||||
}
|
||||
if (fnDef !== undefined && options.conversionRankFn !== undefined) {
|
||||
const allCallables = findAllCallableBindingsInScope(site.inScope, site.name, scopes);
|
||||
const allCallables =
|
||||
eligibleBindingCandidates === undefined
|
||||
? findAllCallableBindingsInScope(site.inScope, site.name, scopes)
|
||||
: eligibleBindingCandidates.map((candidate) => candidate.def);
|
||||
if (allCallables.length > 1) {
|
||||
const narrowed = narrowOverloadCandidates(
|
||||
allCallables,
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ import {
|
|||
callableFlowSiteKey,
|
||||
collectDeferredIndirectSites,
|
||||
emitCallableValueFlow,
|
||||
type CallableValueFlowWarning,
|
||||
} from '../passes/callable-value-flow.js';
|
||||
import type { ScopeResolver } from '../contract/scope-resolver.js';
|
||||
import { findEnclosingClassDef, resolveInheritanceBaseInScope } from '../scope/walkers.js';
|
||||
|
|
@ -92,7 +93,37 @@ import { parseTruthyEnv } from '../../utils/env.js';
|
|||
import { TransitionalScopeTree } from '../../../../storage/scope-index-store.js';
|
||||
import { forceGc } from '../../../../storage/parsedfile-store.js';
|
||||
|
||||
import { logger } from '../../../logger.js';
|
||||
import { logger, warnRespectingProgressBar } from '../../../logger.js';
|
||||
|
||||
/** Exported for the boundary test in run-progress.test.ts. */
|
||||
export const MAX_PROGRESS_WARNING_CONTEXT_CHARS = 160;
|
||||
|
||||
/** Escape control bytes and bound one context shown beside the live bar. */
|
||||
export function formatScopeResolutionWarningContext(context: string): string {
|
||||
const escaped = JSON.stringify(context).slice(1, -1);
|
||||
if (escaped.length <= MAX_PROGRESS_WARNING_CONTEXT_CHARS) return escaped;
|
||||
return `${escaped.slice(0, MAX_PROGRESS_WARNING_CONTEXT_CHARS - 3)}...`;
|
||||
}
|
||||
|
||||
/** One-line progress warning for property-dispatch fan-out drops. */
|
||||
function formatPropertyDispatchProgress(
|
||||
language: string,
|
||||
skippedKeys: number,
|
||||
fanoutCap: number,
|
||||
skippedKeyNames: readonly string[],
|
||||
): string {
|
||||
return ` Warning: property dispatch (${language}) skipped ${skippedKeys} key(s) above fan-out cap ${fanoutCap}; no CALLS were synthesized. Sample: ${skippedKeyNames
|
||||
.slice(0, 5)
|
||||
.join(', ')}`;
|
||||
}
|
||||
|
||||
/** One-line progress warning for callable-value-flow candidate-set overflows. */
|
||||
function formatCallableValueFlowProgress(warning: CallableValueFlowWarning): string {
|
||||
return ` Warning: callable value flow (${warning.language}) skipped ${warning.occurrences} candidate set(s) across ${warning.distinctContexts} context(s) above cap ${warning.cap}; no partial CALLS were emitted. Sample: ${warning.contextSamples
|
||||
.slice(0, 2)
|
||||
.map(formatScopeResolutionWarningContext)
|
||||
.join(', ')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit one class-owned inheritance edge directly (the inheritance pre-pass is
|
||||
|
|
@ -711,6 +742,7 @@ export function runScopeResolution(
|
|||
propagateImportedReturnTypes(parsedFiles, indexes, workspaceIndex);
|
||||
}
|
||||
|
||||
const tRangeBindStart = PROF ? process.hrtime.bigint() : 0n;
|
||||
if (provider.populateRangeBindings !== undefined) {
|
||||
provider.populateRangeBindings(parsedFiles, indexes, {
|
||||
fileContents: getFileContents(),
|
||||
|
|
@ -879,14 +911,23 @@ export function runScopeResolution(
|
|||
// Never drop dispatch coverage silently: a hook table larger than the
|
||||
// fan-out cap means member calls through those keys get no synthesized
|
||||
// CALLS — the #2437 false-safe gap reappears for exactly those keys.
|
||||
logger.warn(
|
||||
warnRespectingProgressBar(
|
||||
formatPropertyDispatchProgress(
|
||||
provider.language,
|
||||
propertyDispatch.skippedKeys,
|
||||
MAX_PROPERTY_DISPATCH_FANOUT,
|
||||
propertyDispatch.skippedKeyNames,
|
||||
),
|
||||
{
|
||||
lang: provider.language,
|
||||
skippedKeys: propertyDispatch.skippedKeys,
|
||||
skippedKeyNames: propertyDispatch.skippedKeyNames,
|
||||
fanoutCap: MAX_PROPERTY_DISPATCH_FANOUT,
|
||||
fields: {
|
||||
lang: provider.language,
|
||||
skippedKeys: propertyDispatch.skippedKeys,
|
||||
skippedKeyNames: propertyDispatch.skippedKeyNames,
|
||||
fanoutCap: MAX_PROPERTY_DISPATCH_FANOUT,
|
||||
},
|
||||
message:
|
||||
'property-dispatch: keys over the fan-out cap were dropped (no CALLS synthesized for them)',
|
||||
},
|
||||
'property-dispatch: keys over the fan-out cap were dropped (no CALLS synthesized for them)',
|
||||
);
|
||||
}
|
||||
const callableValueFlow =
|
||||
|
|
@ -904,15 +945,17 @@ export function runScopeResolution(
|
|||
parsedFiles: emitParsedFiles,
|
||||
nodeLookup: postHeritageNodeLookup,
|
||||
calleeIds: calleeIdAccumulator,
|
||||
canonicalInvokeKeys: deferredIndirectSites,
|
||||
language: provider.language,
|
||||
collapseByCallerTarget: provider.collapseMemberCallsByCallerTarget === true,
|
||||
isCallableValueTarget: provider.isCallableValueTarget,
|
||||
hasFileLocalCallableLinkage: provider.hasFileLocalCallableLinkage,
|
||||
onWarn: (warning) =>
|
||||
logger.warn(
|
||||
warning,
|
||||
'callable-value-flow: candidate set exceeded the cap; no partial CALLS emitted',
|
||||
),
|
||||
warnRespectingProgressBar(formatCallableValueFlowProgress(warning), {
|
||||
fields: warning,
|
||||
message:
|
||||
'callable-value-flow: candidate set exceeded the cap; grouped occurrences emitted no partial CALLS',
|
||||
}),
|
||||
});
|
||||
const importsEmitted = callableFlowOnly
|
||||
? 0
|
||||
|
|
@ -1309,6 +1352,7 @@ export function runScopeResolution(
|
|||
`[scope-resolution prof] extract=${ns(tStart, tExtract).toFixed(0)}ms` +
|
||||
` finalize=${ns(tExtract, tFinalize).toFixed(0)}ms` +
|
||||
` propagate=${ns(tFinalize, tPropagate).toFixed(0)}ms` +
|
||||
` rangeBind=${ns(tRangeBindStart, tPropagate).toFixed(1)}ms` +
|
||||
` resolve=${ns(tPropagate, tResolve).toFixed(0)}ms` +
|
||||
` emit=${ns(tResolve, tEnd).toFixed(0)}ms` +
|
||||
// pdg ⊆ emit: the M2 reaching-defs share of the emit bucket (#2082 U4).
|
||||
|
|
|
|||
|
|
@ -668,6 +668,69 @@ export function findCallableBindingInScope(
|
|||
return findAllCallableBindingsInScope(startScope, callableName, scopes)[0];
|
||||
}
|
||||
|
||||
export interface CallableBindingCandidate {
|
||||
readonly def: SymbolDefinition;
|
||||
/** Every visibility path for this definition, in binding precedence order. */
|
||||
readonly bindings: readonly BindingRef[];
|
||||
}
|
||||
|
||||
function collectCallableBindingCandidates(
|
||||
sources: readonly (readonly BindingRef[] | undefined)[],
|
||||
): readonly CallableBindingCandidate[] {
|
||||
const byNodeId = new Map<string, { def: SymbolDefinition; bindings: BindingRef[] }>();
|
||||
for (const source of sources) {
|
||||
if (source === undefined) continue;
|
||||
for (const binding of source) {
|
||||
const def = binding.def;
|
||||
if (def.type !== 'Function' && def.type !== 'Method' && def.type !== 'Constructor') continue;
|
||||
const existing = byNodeId.get(def.nodeId);
|
||||
if (existing === undefined) {
|
||||
byNodeId.set(def.nodeId, { def, bindings: [binding] });
|
||||
} else {
|
||||
existing.bindings.push(binding);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...byNodeId.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Binding-aware callable lookup for consumers that need visibility evidence.
|
||||
* Unlike `lookupBindingsAt`, duplicate definitions retain every binding path,
|
||||
* so a weaker augmentation can contribute provenance even when a finalized
|
||||
* binding remains the candidate's canonical definition.
|
||||
*/
|
||||
export function findAllCallableBindingCandidatesInScope(
|
||||
startScope: ScopeId,
|
||||
callableName: string,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
): readonly CallableBindingCandidate[] {
|
||||
let currentId: ScopeId | null = startScope;
|
||||
const visited = new Set<ScopeId>();
|
||||
while (currentId !== null) {
|
||||
if (visited.has(currentId)) return [];
|
||||
visited.add(currentId);
|
||||
const scope = scopes.scopeTree.getScope(currentId);
|
||||
if (scope === undefined) return [];
|
||||
|
||||
if (scope.kind !== 'Object') {
|
||||
const lexical = collectCallableBindingCandidates([scope.bindings.get(callableName)]);
|
||||
if (lexical.length > 0) return lexical;
|
||||
|
||||
const candidates = collectCallableBindingCandidates([
|
||||
scopes.bindings.get(currentId)?.get(callableName),
|
||||
scopes.bindingAugmentations.get(currentId)?.get(callableName),
|
||||
collectNamespaceFqnBindings(currentId, callableName, scopes),
|
||||
scopes.workspaceFqnBindings?.get(callableName),
|
||||
]);
|
||||
if (candidates.length > 0) return candidates;
|
||||
}
|
||||
|
||||
currentId = scope.parent;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up all callable bindings (Function/Method/Constructor) by name
|
||||
* from the nearest scope in the chain that binds `callableName`.
|
||||
|
|
|
|||
|
|
@ -699,6 +699,17 @@ export const PYTHON_QUERIES = `
|
|||
(assignment
|
||||
left: (identifier) @name)) @definition.variable
|
||||
|
||||
; Lambda bindings: \`f = lambda x: x\` binds a CALLABLE, so it emits Function
|
||||
; rather than Variable, matching what TS/JS already do for \`const f = () => {}\`.
|
||||
; This aligns the LABEL only — call resolution runs off the scope-resolution
|
||||
; query, which still models the binding as a value, so \`f()\` does not resolve
|
||||
; here yet. Overlap with the assignment pattern above is collapsed by the
|
||||
; parse-worker dedup (#2687).
|
||||
(expression_statement
|
||||
(assignment
|
||||
left: (identifier) @name
|
||||
right: (lambda))) @definition.function
|
||||
|
||||
; Write access: obj.field = value
|
||||
(assignment
|
||||
left: (attribute
|
||||
|
|
@ -868,6 +879,25 @@ export const GO_QUERIES = `
|
|||
; Short variable declaration: x := 5
|
||||
(short_var_declaration left: (expression_list (identifier) @name)) @definition.variable
|
||||
|
||||
; Closure bindings: \`var f = func(){}\` / \`f := func(){}\` bind a CALLABLE, so
|
||||
; they emit Function, not Variable — the same convention TS/JS already use for
|
||||
; \`const f = () => {}\`. This aligns the LABEL only — call resolution runs off
|
||||
; the scope-resolution query, which still models the binding as a value, so
|
||||
; \`f()\` does not resolve here yet. Overlap with the value patterns above is
|
||||
; collapsed by the parse-worker dedup (#2687).
|
||||
(var_declaration
|
||||
(var_spec
|
||||
name: (identifier) @name
|
||||
value: (expression_list (func_literal)))) @definition.function
|
||||
(var_declaration
|
||||
(var_spec_list
|
||||
(var_spec
|
||||
name: (identifier) @name
|
||||
value: (expression_list (func_literal))))) @definition.function
|
||||
(short_var_declaration
|
||||
left: (expression_list (identifier) @name)
|
||||
right: (expression_list (func_literal))) @definition.function
|
||||
|
||||
; Struct literal construction: User{Name: "Alice"}
|
||||
(composite_literal type: (type_identifier) @call.name) @call
|
||||
|
||||
|
|
@ -1031,6 +1061,16 @@ export const CPP_QUERIES = `
|
|||
declarator: (init_declarator
|
||||
declarator: (identifier) @name)) @definition.variable
|
||||
|
||||
; Lambda bindings: \`auto f = [](int x){ … };\` binds a CALLABLE, so it emits
|
||||
; Function rather than Variable, matching TS/JS. This aligns the LABEL only —
|
||||
; call resolution runs off the scope-resolution query, which still models the
|
||||
; binding as a value, so \`f()\` does not resolve here yet. Overlap with the
|
||||
; pattern above is collapsed by the parse-worker dedup (#2687).
|
||||
(declaration
|
||||
declarator: (init_declarator
|
||||
declarator: (identifier) @name
|
||||
value: (lambda_expression))) @definition.function
|
||||
|
||||
; Structured bindings: auto [a, b] = makePair(); (one @name per bound identifier)
|
||||
(declaration
|
||||
declarator: (init_declarator
|
||||
|
|
@ -1379,6 +1419,16 @@ export const KOTLIN_QUERIES = `
|
|||
(variable_declaration
|
||||
(simple_identifier) @name)) @definition.property
|
||||
|
||||
; Lambda bindings: \`val f = { x -> x }\` binds a CALLABLE, so it emits Function
|
||||
; rather than Property, matching TS/JS. This aligns the LABEL only — call
|
||||
; resolution runs off the scope-resolution query, which still models the binding
|
||||
; as a value, so \`f()\` does not resolve here yet. Overlap with the property
|
||||
; pattern above is collapsed by the parse-worker dedup (#2687).
|
||||
(property_declaration
|
||||
(variable_declaration
|
||||
(simple_identifier) @name)
|
||||
(lambda_literal)) @definition.function
|
||||
|
||||
; ── Destructuring declarations (F51, issue #1919) ────────────────────────
|
||||
; "val (a, b) = pair" binds several names through a multi_variable_declaration
|
||||
; (NOT a variable_declaration), which the property rule above misses. Emit one
|
||||
|
|
@ -1503,6 +1553,15 @@ export const SWIFT_QUERIES = `
|
|||
; Properties (stored and computed)
|
||||
(property_declaration (pattern (simple_identifier) @name)) @definition.property
|
||||
|
||||
; Closure bindings: \`let f = { ... }\` binds a CALLABLE, so it emits Function
|
||||
; rather than Property, matching TS/JS. This aligns the LABEL only — call
|
||||
; resolution runs off the scope-resolution query, which still models the binding
|
||||
; as a value, so \`f()\` does not resolve here yet. Overlap with the property
|
||||
; pattern above is collapsed by the parse-worker dedup (#2687).
|
||||
(property_declaration
|
||||
name: (pattern (simple_identifier) @name)
|
||||
value: (lambda_literal)) @definition.function
|
||||
|
||||
; Protocol property requirements (F75): "var title: String { get }" parses to a
|
||||
; protocol_property_declaration (NOT property_declaration). Its name is a
|
||||
; "name:" pattern field wrapping a value_binding_pattern + the bound
|
||||
|
|
@ -1659,6 +1718,16 @@ export const DART_QUERIES = `
|
|||
(initialized_identifier_list
|
||||
(initialized_identifier
|
||||
(identifier) @name)) @definition.variable)
|
||||
; Closure bindings: \`var f = (x) => x;\` binds a CALLABLE, so it emits Function
|
||||
; rather than Variable, matching TS/JS. This aligns the LABEL only — call
|
||||
; resolution runs off the scope-resolution query, which still models the binding
|
||||
; as a value, so \`f()\` does not resolve here yet. Overlap with the pattern
|
||||
; above is collapsed by the parse-worker dedup (#2687).
|
||||
(program
|
||||
(initialized_identifier_list
|
||||
(initialized_identifier
|
||||
(identifier) @name
|
||||
(function_expression))) @definition.function)
|
||||
(program
|
||||
(static_final_declaration_list
|
||||
(static_final_declaration
|
||||
|
|
|
|||
|
|
@ -1,8 +1,4 @@
|
|||
import {
|
||||
findChild,
|
||||
synthesizeJavaAnonymousClassName,
|
||||
type SyntaxNode,
|
||||
} from '../utils/ast-helpers.js';
|
||||
import { findChild, synthesizeJavaTypeIdentity, type SyntaxNode } from '../utils/ast-helpers.js';
|
||||
import type {
|
||||
LanguageTypeConfig,
|
||||
ParameterExtractor,
|
||||
|
|
@ -40,7 +36,7 @@ const JAVA_DECLARATION_NODE_TYPES: ReadonlySet<string> = new Set([
|
|||
const anonymousInitializerTypeName = (declarator: SyntaxNode): string | undefined => {
|
||||
const valueNode = declarator.childForFieldName('value');
|
||||
if (!valueNode || valueNode.type !== 'object_creation_expression') return undefined;
|
||||
return synthesizeJavaAnonymousClassName(valueNode);
|
||||
return synthesizeJavaTypeIdentity(valueNode)?.name;
|
||||
};
|
||||
|
||||
/** Java: Type x = ...; Type x; */
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
templateArgumentsIdTag,
|
||||
} from './template-arguments.js';
|
||||
import { splitQualifiedName } from './qualified-name.js';
|
||||
import { isOverloadableCallable } from './callable-labels.js';
|
||||
|
||||
/** Tree-sitter AST node. Re-exported for use across ingestion modules. */
|
||||
export type SyntaxNode = Parser.SyntaxNode;
|
||||
|
|
@ -110,24 +111,6 @@ const isConcreteTypedefCapture = (captureMap: Record<string, SyntaxNode>): boole
|
|||
);
|
||||
};
|
||||
|
||||
export const buildConcreteTypedefDefinitionRanges = (
|
||||
matches: readonly QueryMatchLike[],
|
||||
): Set<string> => {
|
||||
const ranges = new Set<string>();
|
||||
for (const match of matches) {
|
||||
const captureMap: Record<string, SyntaxNode> = {};
|
||||
for (const capture of match.captures) {
|
||||
captureMap[capture.name] = capture.node;
|
||||
}
|
||||
|
||||
const definitionNode = getDefinitionNodeFromCaptures(captureMap);
|
||||
if (definitionNode && isConcreteTypedefCapture(captureMap)) {
|
||||
ranges.add(nodeRangeKey(definitionNode));
|
||||
}
|
||||
}
|
||||
return ranges;
|
||||
};
|
||||
|
||||
export const isSuppressedConcreteTypedefDuplicate = (
|
||||
captureMap: Record<string, SyntaxNode>,
|
||||
concreteTypedefRanges: ReadonlySet<string>,
|
||||
|
|
@ -140,6 +123,129 @@ export const isSuppressedConcreteTypedefDuplicate = (
|
|||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Graph labels produced by a value capture (`@definition.const` /
|
||||
* `@definition.static` / `@definition.variable`) — a binding that holds a value.
|
||||
*
|
||||
* `Property` is deliberately NOT here. It outranks these: Python matches both
|
||||
* `@definition.property` (annotated) and `@definition.variable` (bare) on one
|
||||
* assignment, and the property must win so a typed class attribute keeps its
|
||||
* `Property` node and its owning `HAS_PROPERTY` edge. `Property` is instead
|
||||
* suppressed only by a *callable* claim — see {@link buildDefinitionNameClaims}.
|
||||
*/
|
||||
const VALUE_DEFINITION_LABELS: ReadonlySet<NodeLabel> = new Set<NodeLabel>([
|
||||
'Const',
|
||||
'Static',
|
||||
'Variable',
|
||||
]);
|
||||
|
||||
/** True when `label` is the kind of node a value capture emits. */
|
||||
export const isValueDefinitionLabel = (label: NodeLabel): boolean =>
|
||||
VALUE_DEFINITION_LABELS.has(label);
|
||||
|
||||
/**
|
||||
* One pass over a file's matches: definition-name claims by rank, plus the
|
||||
* concrete-typedef ranges the loop's separate typedef guard consumes.
|
||||
*/
|
||||
export interface DefinitionPreScan {
|
||||
/**
|
||||
* Keys claimed by any non-value capture — consulted by `Const`/`Static`/
|
||||
* `Variable`. Includes `Property`, so an annotated Python attribute still
|
||||
* beats the bare-assignment `Variable` capture on the same statement.
|
||||
*/
|
||||
readonly nonValue: ReadonlySet<string>;
|
||||
/**
|
||||
* Keys claimed by a *callable* capture (`Function`/`Method`/`Constructor`) —
|
||||
* consulted by `Property`. Narrower than `nonValue` on purpose: a `Property`
|
||||
* must be collapsible by a callable (Kotlin `val f = { … }`, Swift
|
||||
* `let f = { … }`) without being collapsible by its own claim.
|
||||
*/
|
||||
readonly callable: ReadonlySet<string>;
|
||||
/** Ranges of `type_definition` nodes that already emit a concrete struct/enum. */
|
||||
readonly concreteTypedefRanges: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-scan `matches` for the `${definitionNode.startIndex}:${name}` keys already
|
||||
* claimed by a higher-ranked definition capture, so the parse-worker's duplicate
|
||||
* suppression is order-independent.
|
||||
*
|
||||
* Rank, highest first: callable (`Function`/`Method`/`Constructor`) → `Property`
|
||||
* → value (`Const`/`Static`/`Variable`). A capture is dropped only when a
|
||||
* STRICTLY higher rank claimed the same declaration node and name, so no capture
|
||||
* can suppress itself and no rank can suppress a peer.
|
||||
*
|
||||
* ## Why this exists (#2687)
|
||||
*
|
||||
* `const X = () => {}` matches BOTH `@definition.function` and
|
||||
* `@definition.const` on the same `lexical_declaration`. Only one graph node
|
||||
* should survive — the `Function`, because that is what `CALLS` edges target.
|
||||
* The parse-worker's in-loop dedup intends exactly that, but only the value
|
||||
* branch consults its `processedDefinitionNodes` set, so suppression worked only
|
||||
* if the function match happened to be processed first. It is not: tree-sitter
|
||||
* completes the const pattern at `@name`, while the function pattern must also
|
||||
* match the trailing `(arrow_function)` / `(function_expression)` value, so the
|
||||
* const match is yielded FIRST and the edgeless `Const:` twin escaped.
|
||||
*
|
||||
* Consulting this set makes the outcome independent of match order.
|
||||
*
|
||||
* ## Keying
|
||||
*
|
||||
* Keys are `startIndex:name`, never `startIndex` alone — a multi-name
|
||||
* declaration (`const a = 1, b = () => {}`) shares ONE definition node, and a
|
||||
* bare-index key would wrongly suppress `a`'s legitimate `Const` node.
|
||||
*
|
||||
* Labels come from {@link getLabelFromCaptures}, the same function the main loop
|
||||
* uses, so the pre-scan and the loop can never disagree about what counts as a
|
||||
* value capture — including when a provider's `labelOverride` reclassifies one.
|
||||
* A match that resolves to a value label registers nothing, so a match can never
|
||||
* suppress itself.
|
||||
*
|
||||
* Language-agnostic: keyed off capture names and labels only.
|
||||
*
|
||||
* Also collects the concrete-typedef ranges that suppress the analogous
|
||||
* typedef/struct duplicate, so both suppression sets come from one traversal.
|
||||
*/
|
||||
export const buildDefinitionPreScan = (
|
||||
matches: readonly QueryMatchLike[],
|
||||
provider: LanguageProvider,
|
||||
): DefinitionPreScan => {
|
||||
const nonValue = new Set<string>();
|
||||
const callable = new Set<string>();
|
||||
const concreteTypedefRanges = new Set<string>();
|
||||
for (const match of matches) {
|
||||
// ONE capture-map build per match feeds both suppression sets. These used
|
||||
// to be two independent passes over `matches` (each rebuilding this object)
|
||||
// on the hot per-file parse path.
|
||||
const captureMap: Record<string, SyntaxNode> = {};
|
||||
for (const capture of match.captures) {
|
||||
captureMap[capture.name] = capture.node;
|
||||
}
|
||||
|
||||
const definitionNode = getDefinitionNodeFromCaptures(captureMap);
|
||||
if (definitionNode === null) continue;
|
||||
|
||||
if (isConcreteTypedefCapture(captureMap)) {
|
||||
concreteTypedefRanges.add(nodeRangeKey(definitionNode));
|
||||
}
|
||||
|
||||
// No `@name` capture means nothing a lower-ranked capture could collide
|
||||
// with — a value or property pattern always binds a name. Checked before
|
||||
// `getLabelFromCaptures` so a nameless match never pays for label
|
||||
// resolution (which can reach a provider's `labelOverride`).
|
||||
const nameNode = captureMap['name'];
|
||||
if (nameNode === undefined) continue;
|
||||
|
||||
const label = getLabelFromCaptures(captureMap, provider);
|
||||
if (label === null || isValueDefinitionLabel(label)) continue;
|
||||
|
||||
const key = `${definitionNode.startIndex}:${nameNode.text}`;
|
||||
nonValue.add(key);
|
||||
if (isOverloadableCallable(label)) callable.add(key);
|
||||
}
|
||||
return { nonValue, callable, concreteTypedefRanges };
|
||||
};
|
||||
|
||||
/**
|
||||
* Node types that represent function/method definitions across languages.
|
||||
* Used by parent-walk in call-processor, parse-worker, and type-env to detect
|
||||
|
|
@ -405,31 +511,43 @@ export interface EnclosingClassInfo {
|
|||
const MAX_ENCLOSING_WALK_ITERATIONS = 4096;
|
||||
|
||||
/**
|
||||
* Synthesize a javac-style name for a Java anonymous class body:
|
||||
* `new Runnable() { ... }` inside top-level class `Worker` becomes
|
||||
* `Worker$1` (`$N` = 1-based source order of anonymous bodies within the
|
||||
* top-level class). Returns undefined when the node is not an
|
||||
* `object_creation_expression` carrying a `class_body` child — which also
|
||||
* keeps this a no-op for C#, whose `object_creation_expression` uses
|
||||
* `initializer_expression`, never `class_body` (#2550).
|
||||
*
|
||||
* The SAME name must be produced by every layer that keys the anonymous
|
||||
* class (structure-phase node id, enclosing-owner walk, scope-side
|
||||
* declaration synthesis, receiver typeBinding) — they agree by all calling
|
||||
* this one helper.
|
||||
* GitNexus's source-type-relative Java identity for local and anonymous
|
||||
* types. It follows javac's `$N` allocation but intentionally omits the
|
||||
* package prefix because graph ids already include the source file path.
|
||||
*/
|
||||
/** Type-declaration node types that can host (and name) a Java anonymous
|
||||
* class body. Naming follows JLS 13.1: the binary name is the
|
||||
* IMMEDIATELY enclosing type's binary name + `$N`, so the synthesized
|
||||
* name is the `$`-joined chain of enclosing host names
|
||||
* (`EnumWrap$Mode$1`), numbered per immediate host in source order. */
|
||||
const JAVA_ANON_HOST_TYPES = new Set([
|
||||
'class_declaration',
|
||||
'enum_declaration',
|
||||
'interface_declaration',
|
||||
'record_declaration',
|
||||
export interface JavaSynthesizedTypeIdentity {
|
||||
readonly name: string;
|
||||
readonly label: 'Class' | 'Enum' | 'Record' | 'Interface';
|
||||
readonly bindingName?: string;
|
||||
}
|
||||
|
||||
/** Named Java declarations that can host, or themselves be, local types. */
|
||||
const JAVA_NAMED_TYPE_NODE_LABELS = new Map<string, JavaSynthesizedTypeIdentity['label']>([
|
||||
['class_declaration', 'Class'],
|
||||
['enum_declaration', 'Enum'],
|
||||
['interface_declaration', 'Interface'],
|
||||
['record_declaration', 'Record'],
|
||||
]);
|
||||
|
||||
const JAVA_ANON_HOST_TYPES = new Set(JAVA_NAMED_TYPE_NODE_LABELS.keys());
|
||||
const JAVA_LOCAL_TYPE_CONTAINERS = new Set([
|
||||
'block',
|
||||
'constructor_body',
|
||||
'switch_block_statement_group',
|
||||
]);
|
||||
|
||||
/** A legal local type declaration is a class, enum, record, or interface
|
||||
* directly occupying a block-statement position. Annotation interfaces are
|
||||
* deliberately excluded: javac rejects local annotation declarations. */
|
||||
export const javaLocalTypeDeclarationContainer = (node: SyntaxNode): SyntaxNode | null => {
|
||||
if (!JAVA_NAMED_TYPE_NODE_LABELS.has(node.type)) return null;
|
||||
const parent = node.parent;
|
||||
return parent !== null && JAVA_LOCAL_TYPE_CONTAINERS.has(parent.type) ? parent : null;
|
||||
};
|
||||
|
||||
const isJavaLocalTypeNode = (node: SyntaxNode): boolean =>
|
||||
javaLocalTypeDeclarationContainer(node) !== null;
|
||||
|
||||
/** The two Java anonymous-class-body shapes (#2550/#2555): an
|
||||
* `object_creation_expression` with a `class_body` child
|
||||
* (`new Runnable() { ... }`), and an `enum_constant` with a `body:`
|
||||
|
|
@ -439,10 +557,7 @@ const isJavaAnonymousBodyNode = (node: SyntaxNode): boolean =>
|
|||
node.namedChildren?.some((c: SyntaxNode) => c.type === 'class_body') === true) ||
|
||||
(node.type === 'enum_constant' && node.childForFieldName?.('body')?.type === 'class_body');
|
||||
|
||||
/** Nearest ancestor of `node` that is an enclosing TYPE per JLS 13.1 —
|
||||
* a named host declaration OR another anonymous body (both shapes).
|
||||
* Anonymous ancestors chain through: an anon inside an anon is
|
||||
* `Host$1$1`, and an anon inside an enum constant body is `E$1$1`. */
|
||||
/** Nearest ancestor of `node` that is an enclosing type per JLS 13.1. */
|
||||
const nearestJavaEnclosingType = (node: SyntaxNode): SyntaxNode | null => {
|
||||
let cursor: SyntaxNode | null = node.parent;
|
||||
let iterations = 0;
|
||||
|
|
@ -454,83 +569,142 @@ const nearestJavaEnclosingType = (node: SyntaxNode): SyntaxNode | null => {
|
|||
return null;
|
||||
};
|
||||
|
||||
/** Per-parse-tree memo of anonymous-body numbering: tree → (startIndex →
|
||||
* synthesized name). Keyed by the tree OBJECT via WeakMap so entries die
|
||||
* with the parse; without it every call re-scans the host subtree
|
||||
* (`descendantsOfType`), and the helper is called from four independent
|
||||
* layers per anonymous body — quadratic on anon-heavy files (old-style
|
||||
* listener-per-widget Java). */
|
||||
const javaAnonNameMemo = new WeakMap<object, Map<number, string>>();
|
||||
interface JavaTypeIdentityState {
|
||||
readonly byStart: Map<number, JavaSynthesizedTypeIdentity>;
|
||||
readonly ordinalByStart: Map<number, number>;
|
||||
}
|
||||
|
||||
export const synthesizeJavaAnonymousClassName = (node: SyntaxNode): string | undefined => {
|
||||
if (!isJavaAnonymousBodyNode(node)) return undefined;
|
||||
/** Parse-tree-bounded memo. Sequence ordinals are built once per tree, avoiding
|
||||
* a host-candidate scan for every extraction/ownership consumer. */
|
||||
const javaTypeIdentityMemo = new WeakMap<object, JavaTypeIdentityState>();
|
||||
|
||||
const tree = (node as { tree?: object }).tree;
|
||||
if (tree !== undefined) {
|
||||
const cached = javaAnonNameMemo.get(tree)?.get(node.startIndex);
|
||||
if (cached !== undefined) return cached;
|
||||
const javaHostKey = (node: SyntaxNode): string => `${node.type}:${node.startIndex}`;
|
||||
|
||||
const javaIdentityCandidatesBelow = (root: SyntaxNode): SyntaxNode[] => {
|
||||
const seen = new Set<string>();
|
||||
const candidates: SyntaxNode[] = [];
|
||||
for (const type of [
|
||||
'object_creation_expression',
|
||||
'enum_constant',
|
||||
...JAVA_NAMED_TYPE_NODE_LABELS.keys(),
|
||||
]) {
|
||||
for (const candidate of root.descendantsOfType?.(type) ?? []) {
|
||||
if (!isJavaAnonymousBodyNode(candidate) && !isJavaLocalTypeNode(candidate)) continue;
|
||||
const key = javaHostKey(candidate);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
candidates.push(candidate);
|
||||
}
|
||||
}
|
||||
return candidates.sort((left, right) => left.startIndex - right.startIndex);
|
||||
};
|
||||
|
||||
// JLS 13.1: the binary name is the IMMEDIATELY ENCLOSING TYPE's binary
|
||||
// name + `$N`. The enclosing type may itself be anonymous — then its
|
||||
// own synthesized name is the prefix (recursion, memo-bounded):
|
||||
// `NestHost$1$1` for an anon inside an anon, `E$1$1` for an anon
|
||||
// inside an enum constant body. For a named enclosing type the prefix
|
||||
// is the `$`-joined chain of named hosts (`EnumWrap$Mode`).
|
||||
const buildJavaTypeIdentityState = (root: SyntaxNode): JavaTypeIdentityState => {
|
||||
const ordinalByStart = new Map<number, number>();
|
||||
const sequenceCounts = new Map<string, number>();
|
||||
for (const candidate of javaIdentityCandidatesBelow(root)) {
|
||||
const host = nearestJavaEnclosingType(candidate);
|
||||
if (host === null) continue;
|
||||
const isAnonymous = isJavaAnonymousBodyNode(candidate);
|
||||
const bindingName = isAnonymous ? '' : candidate.childForFieldName?.('name')?.text;
|
||||
// Anonymous types deliberately use the empty sequence key; malformed named
|
||||
// declarations must not enter that sequence.
|
||||
if (!isAnonymous && !bindingName) continue;
|
||||
const sequenceKey = `${javaHostKey(host)}:${bindingName}`;
|
||||
const ordinal = (sequenceCounts.get(sequenceKey) ?? 0) + 1;
|
||||
sequenceCounts.set(sequenceKey, ordinal);
|
||||
ordinalByStart.set(candidate.startIndex, ordinal);
|
||||
}
|
||||
return { byStart: new Map(), ordinalByStart };
|
||||
};
|
||||
|
||||
const javaTypeIdentityStateFor = (node: SyntaxNode): JavaTypeIdentityState => {
|
||||
const tree = (node as { tree?: { rootNode?: SyntaxNode } }).tree;
|
||||
if (tree === undefined) {
|
||||
const host = nearestJavaEnclosingType(node);
|
||||
return buildJavaTypeIdentityState(host ?? node);
|
||||
}
|
||||
let state = javaTypeIdentityMemo.get(tree);
|
||||
if (state === undefined) {
|
||||
state = buildJavaTypeIdentityState(tree.rootNode ?? node);
|
||||
javaTypeIdentityMemo.set(tree, state);
|
||||
}
|
||||
return state;
|
||||
};
|
||||
|
||||
/** Source-type-relative binary name of a Java enclosing type, including
|
||||
* synthesized local/anonymous hosts and named member-type chains. */
|
||||
const javaBinaryNameOfType = (node: SyntaxNode): string | undefined => {
|
||||
if (isJavaAnonymousBodyNode(node) || isJavaLocalTypeNode(node)) {
|
||||
return synthesizeJavaTypeIdentity(node)?.name;
|
||||
}
|
||||
if (!JAVA_ANON_HOST_TYPES.has(node.type)) return undefined;
|
||||
const simpleName = node.childForFieldName?.('name')?.text;
|
||||
if (simpleName === undefined || simpleName.length === 0) return undefined;
|
||||
const enclosing = nearestJavaEnclosingType(node);
|
||||
if (enclosing === null) return simpleName;
|
||||
const enclosingName = javaBinaryNameOfType(enclosing);
|
||||
return enclosingName === undefined ? undefined : `${enclosingName}$${simpleName}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Authoritative Java local/anonymous type identity.
|
||||
*
|
||||
* JLS 13.1 defines the shape and immediate-host prefix. OpenJDK javac's
|
||||
* Check.localClassName allocates N independently for each
|
||||
* (enclosing binary name, local simple name) pair; anonymous types use the
|
||||
* empty simple name and therefore have their own sequence. Package names are
|
||||
* omitted from this project identity because graph ids already include the
|
||||
* file path.
|
||||
*/
|
||||
export const synthesizeJavaTypeIdentity = (
|
||||
node: SyntaxNode,
|
||||
): JavaSynthesizedTypeIdentity | undefined => {
|
||||
const localLabel = JAVA_NAMED_TYPE_NODE_LABELS.get(node.type);
|
||||
const isLocal = localLabel !== undefined && isJavaLocalTypeNode(node);
|
||||
const isAnonymous = isJavaAnonymousBodyNode(node);
|
||||
const enclosing = nearestJavaEnclosingType(node);
|
||||
const memberSimpleName =
|
||||
!isLocal && !isAnonymous && localLabel !== undefined
|
||||
? node.childForFieldName?.('name')?.text
|
||||
: undefined;
|
||||
const synthesizedHostIdentity =
|
||||
memberSimpleName !== undefined && enclosing !== null
|
||||
? synthesizeJavaTypeIdentity(enclosing)
|
||||
: undefined;
|
||||
if (!isLocal && !isAnonymous && synthesizedHostIdentity === undefined) return undefined;
|
||||
if (enclosing === null) return undefined;
|
||||
let prefix: string;
|
||||
if (isJavaAnonymousBodyNode(enclosing)) {
|
||||
const enclosingName = synthesizeJavaAnonymousClassName(enclosing);
|
||||
if (enclosingName === undefined) return undefined;
|
||||
prefix = enclosingName;
|
||||
} else {
|
||||
const hostNames: string[] = [];
|
||||
let cursor: SyntaxNode | null = enclosing;
|
||||
let iterations = 0;
|
||||
while (cursor) {
|
||||
if (++iterations > MAX_ENCLOSING_WALK_ITERATIONS) return undefined;
|
||||
if (JAVA_ANON_HOST_TYPES.has(cursor.type)) {
|
||||
const hostName = cursor.childForFieldName?.('name')?.text;
|
||||
if (hostName === undefined || hostName.length === 0) return undefined;
|
||||
hostNames.unshift(hostName);
|
||||
}
|
||||
cursor = cursor.parent;
|
||||
}
|
||||
prefix = hostNames.join('$');
|
||||
|
||||
const state = javaTypeIdentityStateFor(node);
|
||||
const cached = state.byStart.get(node.startIndex);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
const prefix = javaBinaryNameOfType(enclosing);
|
||||
if (prefix === undefined) return undefined;
|
||||
|
||||
if (memberSimpleName !== undefined) {
|
||||
const identity: JavaSynthesizedTypeIdentity = {
|
||||
name: `${prefix}$${memberSimpleName}`,
|
||||
label: localLabel!,
|
||||
bindingName: memberSimpleName,
|
||||
};
|
||||
state.byStart.set(node.startIndex, identity);
|
||||
return identity;
|
||||
}
|
||||
|
||||
// All anonymous bodies (both shapes) whose immediately enclosing TYPE
|
||||
// is THIS one, in source order. `descendantsOfType` over the subtree
|
||||
// also finds bodies belonging to nested enclosing types — filter them
|
||||
// out by re-deriving each candidate's own enclosing type.
|
||||
const candidates = [
|
||||
...(enclosing.descendantsOfType?.('object_creation_expression') ?? []),
|
||||
...(enclosing.descendantsOfType?.('enum_constant') ?? []),
|
||||
]
|
||||
.filter(isJavaAnonymousBodyNode)
|
||||
.filter((c: SyntaxNode) => {
|
||||
const host = nearestJavaEnclosingType(c);
|
||||
return (
|
||||
host !== null && host.startIndex === enclosing.startIndex && host.type === enclosing.type
|
||||
);
|
||||
})
|
||||
.sort((a: SyntaxNode, b: SyntaxNode) => a.startIndex - b.startIndex);
|
||||
const bindingName = isLocal ? node.childForFieldName?.('name')?.text : undefined;
|
||||
if (isLocal && !bindingName) return undefined;
|
||||
|
||||
if (tree !== undefined) {
|
||||
let byStart = javaAnonNameMemo.get(tree);
|
||||
if (byStart === undefined) {
|
||||
byStart = new Map();
|
||||
javaAnonNameMemo.set(tree, byStart);
|
||||
}
|
||||
for (let i = 0; i < candidates.length; i++) {
|
||||
byStart.set(candidates[i]!.startIndex, `${prefix}$${i + 1}`);
|
||||
}
|
||||
return byStart.get(node.startIndex);
|
||||
}
|
||||
const index = candidates.findIndex((c: SyntaxNode) => c.startIndex === node.startIndex);
|
||||
if (index === -1) return undefined;
|
||||
return `${prefix}$${index + 1}`;
|
||||
const ordinal = state.ordinalByStart.get(node.startIndex);
|
||||
if (ordinal === undefined) return undefined;
|
||||
|
||||
const identity: JavaSynthesizedTypeIdentity = {
|
||||
name: `${prefix}$${ordinal}${bindingName ?? ''}`,
|
||||
label: isAnonymous ? 'Class' : localLabel!,
|
||||
...(bindingName === undefined ? {} : { bindingName }),
|
||||
};
|
||||
state.byStart.set(node.startIndex, identity);
|
||||
return identity;
|
||||
};
|
||||
|
||||
export const findEnclosingClassInfo = (
|
||||
|
|
@ -605,12 +779,12 @@ export const findEnclosingClassInfo = (
|
|||
// enum constant, and every C# `object_creation_expression`), so the
|
||||
// walk continues unchanged for those — including on to
|
||||
// `enum_declaration`, which sits in CLASS_CONTAINER_TYPES below.
|
||||
if (current.type === 'object_creation_expression' || current.type === 'enum_constant') {
|
||||
const anonName = synthesizeJavaAnonymousClassName(current);
|
||||
if (anonName !== undefined) {
|
||||
if (isJavaAnonymousBodyNode(current) || JAVA_ANON_HOST_TYPES.has(current.type)) {
|
||||
const identity = synthesizeJavaTypeIdentity(current);
|
||||
if (identity !== undefined) {
|
||||
return {
|
||||
classId: generateId('Class', `${filePath}:${anonName}`),
|
||||
className: anonName,
|
||||
classId: generateId(identity.label, `${filePath}:${identity.name}`),
|
||||
className: identity.name,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
67
gitnexus/src/core/ingestion/utils/effective-ram.ts
Normal file
67
gitnexus/src/core/ingestion/utils/effective-ram.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import os from 'node:os';
|
||||
|
||||
/**
|
||||
* Effective RAM in bytes: physical total, or a REAL smaller cgroup limit
|
||||
* (#2649). `process.constrainedMemory()` returns a huge sentinel when
|
||||
* unconstrained, and only the leaf cgroup's limit is visible (parent-slice
|
||||
* caps are not) — so a smaller-than-physical value is trusted and anything
|
||||
* else falls back to `os.totalmem()`. Mirrors `computeHeapCapMb`'s
|
||||
* constrained handling in `cli/analyze.ts`; container-blind sizing told
|
||||
* users "this machine has more memory" inside an 8GB-limited container on
|
||||
* a 64GB host, and sized worker heap caps past the whole container.
|
||||
*/
|
||||
export function effectiveRamBytes(): number {
|
||||
const total = os.totalmem();
|
||||
const constrained =
|
||||
typeof process.constrainedMemory === 'function' ? process.constrainedMemory() : undefined;
|
||||
return typeof constrained === 'number' && constrained > 0 && constrained < total
|
||||
? constrained
|
||||
: total;
|
||||
}
|
||||
|
||||
/** Historical floor for the auto heap cap — applied only up to 0.80 × RAM
|
||||
* (a floor at or above physical memory swap-thrashes instead of OOMing,
|
||||
* #2649). */
|
||||
const HEAP_FLOOR_MB = 16384;
|
||||
|
||||
/**
|
||||
* The RAM-aware heap cap formula (#2649), single-sourced here so the CLI
|
||||
* respawn (`computeHeapCapMb` in `cli/analyze.ts`), and the server's
|
||||
* analyze fork size from the same rule: `0.75 × effective RAM`, raised to
|
||||
* the floor when RAM allows, never above `0.80 × effective RAM`.
|
||||
*/
|
||||
export function heapCapMbFor(effectiveBytes: number): number {
|
||||
const effectiveMb = Math.floor(effectiveBytes / (1024 * 1024));
|
||||
return Math.min(
|
||||
Math.max(HEAP_FLOOR_MB, Math.floor(0.75 * effectiveMb)),
|
||||
Math.floor(0.8 * effectiveMb),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the operator has turned GitNexus's memory autopilot off
|
||||
* (`GITNEXUS_MEMORY=off`).
|
||||
*
|
||||
* One switch for one concern. Memory management has two automatic behaviours —
|
||||
* re-running analyze with a RAM-aware heap cap, and aborting the parse before
|
||||
* V8's ineffective-mark-compact death spiral — and an operator who wants to
|
||||
* drive manually wants both off, not one. They were previously two separate
|
||||
* variables (`GITNEXUS_AUTO_HEAP`, `GITNEXUS_HEAP_GUARD`), which is three knobs
|
||||
* for one intent once the worker-heap override is counted; neither had shipped,
|
||||
* so this consolidates them rather than deprecating anything.
|
||||
*
|
||||
* Note the ordinary way to pin the heap is Node's own `--max-old-space-size`,
|
||||
* which `ensureHeap` already honours as the operator's decision. This switch is
|
||||
* for declining the autopilot WITHOUT naming a size.
|
||||
*
|
||||
* Lives here beside the cap formula so policy and its escape hatch are
|
||||
* single-sourced. Read every call (not memoized) so tests can stub the env.
|
||||
*/
|
||||
export function memoryAutopilotDisabled(): boolean {
|
||||
return process.env.GITNEXUS_MEMORY === 'off';
|
||||
}
|
||||
|
||||
/** The cap for THIS machine/container: `heapCapMbFor(effectiveRamBytes())`. */
|
||||
export function autoHeapCapMb(): number {
|
||||
return heapCapMbFor(effectiveRamBytes());
|
||||
}
|
||||
|
|
@ -78,7 +78,7 @@ try {
|
|||
} catch {}
|
||||
import { getLanguageFromFilename } from 'gitnexus-shared';
|
||||
import {
|
||||
buildConcreteTypedefDefinitionRanges,
|
||||
buildDefinitionPreScan,
|
||||
FUNCTION_NODE_TYPES,
|
||||
findAncestorBeforeBoundary,
|
||||
getDefinitionNodeFromCaptures,
|
||||
|
|
@ -89,6 +89,7 @@ import {
|
|||
genericFuncName,
|
||||
inferFunctionLabel,
|
||||
isSuppressedConcreteTypedefDuplicate,
|
||||
isValueDefinitionLabel,
|
||||
isQualifiableScopeLabel,
|
||||
qualifyRustImplTargetByModScope,
|
||||
CLASS_CONTAINER_TYPES,
|
||||
|
|
@ -1313,10 +1314,15 @@ const processFileGroup = (
|
|||
);
|
||||
continue;
|
||||
}
|
||||
const concreteTypedefRanges = buildConcreteTypedefDefinitionRanges(matches);
|
||||
|
||||
const provider = getProvider(language);
|
||||
|
||||
// #2687: ONE pass over `matches` yields both suppression sets — the
|
||||
// definition-name claims by rank (callable > Property > value), so the dedup
|
||||
// below cannot depend on tree-sitter's match order, and the concrete-typedef
|
||||
// ranges the typedef guard consumes.
|
||||
const definitionPreScan = buildDefinitionPreScan(matches, provider);
|
||||
const concreteTypedefRanges = definitionPreScan.concreteTypedefRanges;
|
||||
|
||||
// Produce the `ParsedFile` for the scope-resolution pipeline HERE, reusing
|
||||
// the tree we just parsed (no second tree-sitter parse). Scope-resolution
|
||||
// consumes these via the disk-backed parsedfile-store instead of
|
||||
|
|
@ -1967,19 +1973,41 @@ const processFileGroup = (
|
|||
// Dedup: variable captures (Const/Static/Variable) may overlap with higher-priority
|
||||
// captures (e.g. `const fn = () => {}` matches both @definition.function and @definition.const).
|
||||
// Multi-name declarations share the same definition node, so include the emitted name.
|
||||
//
|
||||
// `processedDefinitionNodes` alone only suppressed the value twin when the
|
||||
// function-like match happened to be processed FIRST — and it is not.
|
||||
// tree-sitter completes `@definition.const` at `@name`, while
|
||||
// `@definition.function` must also match the trailing arrow / function
|
||||
// expression, so the const match is yielded first and its edgeless twin
|
||||
// escaped (#2687). `definitionPreScan` is the order-independent view of
|
||||
// the same claim, pre-scanned over `matches` before this loop and ranked so
|
||||
// a capture is dropped only by a STRICTLY higher-ranked claimant.
|
||||
//
|
||||
// It also replaces the old bare-`startIndex` claim, which was too coarse:
|
||||
// a callable declared FIRST in a multi-name declaration
|
||||
// (`const cb = () => 1, SIBLING = 2`) registered the shared definition
|
||||
// node and silently dropped every later sibling on it. Both keys are now
|
||||
// name-scoped, so siblings survive in either declarator order.
|
||||
//
|
||||
// The long-term collapse seam for this duplicate class is
|
||||
// `selectNodeBearingDef` (#1876, still unwired); this pre-scan is the local
|
||||
// form that keeps the hot loop single-pass. Keep them in sync if #1876 lands.
|
||||
if (definitionNode) {
|
||||
const definitionBaseKey = `${definitionNode.startIndex}`;
|
||||
if (nodeLabel === 'Const' || nodeLabel === 'Static' || nodeLabel === 'Variable') {
|
||||
const definitionNameKey = `${definitionBaseKey}:${nodeName}`;
|
||||
const definitionNameKey = `${definitionNode.startIndex}:${nodeName}`;
|
||||
if (isValueDefinitionLabel(nodeLabel)) {
|
||||
if (
|
||||
processedDefinitionNodes.has(definitionBaseKey) ||
|
||||
processedDefinitionNodes.has(definitionNameKey)
|
||||
processedDefinitionNodes.has(definitionNameKey) ||
|
||||
definitionPreScan.nonValue.has(definitionNameKey)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
processedDefinitionNodes.add(definitionNameKey);
|
||||
} else {
|
||||
processedDefinitionNodes.add(definitionBaseKey);
|
||||
} else if (nodeLabel === 'Property' && definitionPreScan.callable.has(definitionNameKey)) {
|
||||
// Only a CALLABLE collapses a property. Consulting the wider
|
||||
// `nonValue` set here would let a property suppress itself, and would
|
||||
// let an annotated Python attribute lose to its own bare-assignment
|
||||
// twin — the property must outrank `Variable`, not tie with it.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Worker } from 'node:worker_threads';
|
||||
import os from 'node:os';
|
||||
import { effectiveRamBytes } from '../utils/effective-ram.js';
|
||||
import fs from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
|
|
@ -224,6 +225,13 @@ export interface WorkerPoolOptions {
|
|||
* code should leave this unset.
|
||||
*/
|
||||
workerFactory?: (workerUrl: URL) => Worker;
|
||||
/**
|
||||
* Test-only injection point for the main-thread stall probe (#2649):
|
||||
* returns cumulative event-loop stall in ms. When provided, the pool
|
||||
* skips its heartbeat tracker and reads this instead. Production code
|
||||
* should leave this unset.
|
||||
*/
|
||||
stallMsProbe?: () => number;
|
||||
/**
|
||||
* Storage path for the disk-backed ParsedFile store (#1983 parallel
|
||||
* serialization). When set, it is baked into every spawned worker's
|
||||
|
|
@ -811,7 +819,7 @@ function waitForWorkerReady(worker: Worker, readyTimeoutMs: number): Promise<voi
|
|||
new Error(
|
||||
withStderr(
|
||||
worker,
|
||||
`Replacement worker did not report ready within ${readyTimeoutMs}ms — likely crashed during top-of-script init (slow host? raise GITNEXUS_WORKER_READY_TIMEOUT_MS)`,
|
||||
`Replacement worker did not report ready within ${readyTimeoutMs}ms — likely crashed during top-of-script init (slow host? raise GITNEXUS_WORKER_READY_TIMEOUT_MS; repeated on a large repo? likely main-thread memory pressure — see the "Analysis runs out of memory" README section, #2649)`,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
@ -925,6 +933,53 @@ function createJobs<TInput>(
|
|||
* single non-cloneable value can't masquerade as a worker death and exhaust a
|
||||
* slot's respawn budget here.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Main-thread stall tracking (#2649). Near the V8 heap limit, multi-second
|
||||
* mark-compact pauses freeze the main thread's message processing, so a
|
||||
* healthy worker's `progress` messages sit unread and the worker LOOKS idle —
|
||||
* the idle-timeout path then splits/retires it, and the respawn storm ends in
|
||||
* "Replacement worker did not report ready". A 250ms unref'd heartbeat
|
||||
* accumulates observed event-loop drift; the idle-timeout handler credits
|
||||
* that stall once per job instead of retiring a worker the main thread
|
||||
* starved. The floor filters scheduler jitter from real stalls.
|
||||
*/
|
||||
const HEARTBEAT_INTERVAL_MS = 250;
|
||||
const HEARTBEAT_STALL_FLOOR_MS = 100;
|
||||
/** Fraction of the idle-timeout budget that must be main-thread stall before
|
||||
* the timeout is credited and re-armed instead of acted on. */
|
||||
const STALL_CREDIT_FRACTION = 0.5;
|
||||
|
||||
export function startHeartbeatStallTracker(): { read: () => number; stop: () => void } {
|
||||
let totalStallMs = 0;
|
||||
let last = Date.now();
|
||||
const handle = setInterval(() => {
|
||||
const now = Date.now();
|
||||
const drift = now - last - HEARTBEAT_INTERVAL_MS;
|
||||
if (drift > HEARTBEAT_STALL_FLOOR_MS) totalStallMs += drift;
|
||||
last = now;
|
||||
}, HEARTBEAT_INTERVAL_MS);
|
||||
handle.unref?.();
|
||||
return { read: () => totalStallMs, stop: () => clearInterval(handle) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-worker V8 old-generation heap cap in MB (#2649). Without one, worker
|
||||
* isolates inherit an unbounded default and a full pool can inflate process
|
||||
* RSS past physical RAM on large repos. Half of RAM split across the pool,
|
||||
* clamped to [512, 4096] MB — generous for the per-sub-batch working set
|
||||
* (jobs are byte-budgeted), and a worker that does exceed it dies with a
|
||||
* real heap error surfaced by the stderr-tail machinery + the
|
||||
* quarantine/respawn path, instead of silently dragging the host into swap.
|
||||
* `GITNEXUS_WORKER_HEAP_MB` overrides the formula. Exported for unit tests.
|
||||
*/
|
||||
export function resolveWorkerHeapCapMb(poolSize: number): number {
|
||||
return (
|
||||
positiveInteger(process.env.GITNEXUS_WORKER_HEAP_MB) ??
|
||||
Math.min(4096, Math.max(512, Math.floor(effectiveRamBytes() / (1024 * 1024) / 2 / poolSize)))
|
||||
);
|
||||
}
|
||||
|
||||
export const createWorkerPool = (
|
||||
workerUrl: URL,
|
||||
poolSize?: number,
|
||||
|
|
@ -957,6 +1012,24 @@ export const createWorkerPool = (
|
|||
parsedFileStoreStoragePath || durableParsedFileStoragePath || pdg
|
||||
? { parsedFileStoreStoragePath, durableParsedFileStoragePath, pdg, pdgMaxFunctionLines }
|
||||
: undefined;
|
||||
const workerHeapCapMb = resolveWorkerHeapCapMb(size);
|
||||
// The 512MB per-worker floor exists so a worker can parse anything real,
|
||||
// but on a very small container a large pool of floored workers can still
|
||||
// overcommit total memory (#2649 review). Behavior is unchanged — deaths
|
||||
// are attributed and quarantine converges — but say so up front, with the
|
||||
// two levers, instead of letting the operator discover it from worker OOMs.
|
||||
const poolCommitMb = workerHeapCapMb * size;
|
||||
const effectiveMb = Math.floor(effectiveRamBytes() / (1024 * 1024));
|
||||
if (poolCommitMb > 0.6 * effectiveMb) {
|
||||
logger.warn(
|
||||
{ poolSize: size, workerHeapCapMb, effectiveMb },
|
||||
`Worker pool may overcommit memory: ${size} workers × ${workerHeapCapMb}MB heap cap exceeds 60% of the ${effectiveMb}MB available to this process. Reduce GITNEXUS_WORKER_POOL_SIZE or set GITNEXUS_WORKER_HEAP_MB.`,
|
||||
);
|
||||
}
|
||||
// #2649 stall probe: test seam wins; production uses the heartbeat tracker.
|
||||
const stallTracker = options?.stallMsProbe
|
||||
? { read: options.stallMsProbe, stop: (): void => undefined }
|
||||
: startHeartbeatStallTracker();
|
||||
const spawnWorker =
|
||||
options?.workerFactory ??
|
||||
((url: URL) =>
|
||||
|
|
@ -976,7 +1049,7 @@ export const createWorkerPool = (
|
|||
// nesting levels (far beyond any hand-written code); a deeper machine-
|
||||
// generated nest is still caught per-function (buildFunctionCfg's R4
|
||||
// try/catch) and only that function's PDG is skipped, never a crash.
|
||||
resourceLimits: { stackSizeMb: 16 },
|
||||
resourceLimits: { stackSizeMb: 16, maxOldGenerationSizeMb: workerHeapCapMb },
|
||||
}));
|
||||
/** Spawn + wire stdio capture/forwarding in one step (used by all spawn sites). */
|
||||
const spawnAndCapture = (url: URL): Worker => {
|
||||
|
|
@ -1843,10 +1916,28 @@ export const createWorkerPool = (
|
|||
maybeDone();
|
||||
};
|
||||
|
||||
let stallCreditUsed = false;
|
||||
let stallAtArm = 0;
|
||||
const resetIdleTimer = () => {
|
||||
if (idleTimer) clearTimeout(idleTimer);
|
||||
stallAtArm = stallTracker.read();
|
||||
idleTimer = setTimeout(() => {
|
||||
if (!settled) {
|
||||
// #2649: when at least STALL_CREDIT_FRACTION of the timeout
|
||||
// window was main-thread stall (GC pressure near the heap
|
||||
// limit), the worker's progress messages were starved, not
|
||||
// absent — credit the stall once per job and re-arm instead
|
||||
// of splitting/retiring a healthy worker.
|
||||
const stallMs = stallTracker.read() - stallAtArm;
|
||||
if (!stallCreditUsed && stallMs >= job.timeoutMs * STALL_CREDIT_FRACTION) {
|
||||
stallCreditUsed = true;
|
||||
logger.warn(
|
||||
{ workerIndex, stallMs: Math.round(stallMs), timeoutMs: job.timeoutMs },
|
||||
`Worker ${workerIndex} idle timeout overlapped a main-thread stall (GC pressure); re-arming once instead of retiring.`,
|
||||
);
|
||||
resetIdleTimer();
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
cleanup();
|
||||
inFlightProgress[workerIndex] = 0;
|
||||
|
|
@ -2084,10 +2175,22 @@ export const createWorkerPool = (
|
|||
// the `{type:'error'}` message, the event delivers a real Error whose
|
||||
// `.stack` is the worker-side frame — carry it so the surfaced reason
|
||||
// points at the actual failure site, not just `err.message` (#2068).
|
||||
void recoverAndResume(
|
||||
workerErrorReason(workerIndex, err.message, err.stack),
|
||||
resolveExcludePaths(),
|
||||
);
|
||||
// A worker dying on ITS OWN heap cap (#2649) must be attributable to
|
||||
// that cap, not read as generic quarantine noise — name the cap and
|
||||
// its override so an oversized-but-legitimate file (e.g. under a
|
||||
// raised GITNEXUS_MAX_FILE_SIZE) is a one-env-var fix.
|
||||
// The 'error' event does not guarantee a well-formed Error: the
|
||||
// structured-clone failure path can deliver a value with no
|
||||
// `message` — guard every property access or the handler itself
|
||||
// throws and the pool hangs instead of recovering.
|
||||
const isWorkerHeapOom =
|
||||
(err as NodeJS.ErrnoException | undefined)?.code === 'ERR_WORKER_OUT_OF_MEMORY' ||
|
||||
(typeof err?.message === 'string' &&
|
||||
err.message.includes('ERR_WORKER_OUT_OF_MEMORY'));
|
||||
const reason = isWorkerHeapOom
|
||||
? `${workerErrorReason(workerIndex, err.message, err.stack)} (worker hit its ${workerHeapCapMb}MB heap cap — raise with GITNEXUS_WORKER_HEAP_MB)`
|
||||
: workerErrorReason(workerIndex, err.message, err.stack);
|
||||
void recoverAndResume(reason, resolveExcludePaths());
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -2154,6 +2257,7 @@ export const createWorkerPool = (
|
|||
|
||||
const terminate = async (): Promise<void> => {
|
||||
terminated = true;
|
||||
stallTracker.stop();
|
||||
// Cancel any in-flight startup backoff so its ref'd timer doesn't keep the
|
||||
// event loop alive after terminate; each cancel resolves the awaiting sleep
|
||||
// and the slot loop then sees `terminated` and gives up (#1741).
|
||||
|
|
|
|||
|
|
@ -127,13 +127,25 @@ const VC_REDIST_INSTALL_HINT =
|
|||
'the Microsoft Visual C++ 2015-2022 Redistributable (x64) from ' +
|
||||
'https://aka.ms/vs/17/release/vc_redist.x64.exe';
|
||||
|
||||
// Git for Windows already ships the OpenSSL 3 DLLs in its mingw64 bin directory,
|
||||
// so the identical command that fails in PowerShell succeeds in Git Bash (#2669
|
||||
// reporter, who had the VC++ redist installed and still failed until that
|
||||
// directory was on PATH). Deliberately a fixed system path and never a
|
||||
// user-profile one: remedy text is NOT path-redacted (fts-indexes.ts redacts
|
||||
// only the reason), and fts-degraded-warning.test.ts asserts that no
|
||||
// `C:\Users\…` path ever reaches a user through this surface.
|
||||
const GIT_BASH_OPENSSL_HINT =
|
||||
' If Git for Windows is installed you already have those DLLs: run the same command from Git Bash, ' +
|
||||
'or prepend "C:\\Program Files\\Git\\mingw64\\bin" to PATH.';
|
||||
|
||||
// MSVC-first per DuckDB's canonical answer for this exact error; OpenSSL second.
|
||||
const windowsMissingDependencyRemedy = (label: string): string =>
|
||||
`The ${label} extension is present but a required runtime library is missing (Windows error 126). ` +
|
||||
'Reinstalling the extension will NOT help. Install ' +
|
||||
VC_REDIST_INSTALL_HINT +
|
||||
'; if the error persists, the extension also needs OpenSSL 3 ' +
|
||||
'(libcrypto-3-x64.dll / libssl-3-x64.dll) on the DLL search path.';
|
||||
'(libcrypto-3-x64.dll / libssl-3-x64.dll) on the DLL search path.' +
|
||||
GIT_BASH_OPENSSL_HINT;
|
||||
|
||||
const posixMissingDependencyRemedy = (label: string): string =>
|
||||
`The ${label} extension is present but a shared library it depends on could not be loaded (named in ` +
|
||||
|
|
@ -205,7 +217,8 @@ const structuralMissingDependencyRemedy = (label: string): string =>
|
|||
`The ${label} extension file is valid, so the failure is a missing or incompatible runtime dependency, ` +
|
||||
'not the extension itself — reinstalling will NOT help. On Windows, install ' +
|
||||
VC_REDIST_INSTALL_HINT +
|
||||
' and ensure OpenSSL 3 is available; on Linux/macOS install the shared library named in the error above.';
|
||||
' and ensure OpenSSL 3 is available; on Linux/macOS install the shared library named in the error above.' +
|
||||
GIT_BASH_OPENSSL_HINT;
|
||||
|
||||
/**
|
||||
* Pull the extension file path out of lbug's load error. lbug's wrapper is
|
||||
|
|
|
|||
633
gitnexus/src/core/lbug/graph-emit-sink.ts
Normal file
633
gitnexus/src/core/lbug/graph-emit-sink.ts
Normal file
|
|
@ -0,0 +1,633 @@
|
|||
/**
|
||||
* Streaming structural graph-emit sink (issue #2680).
|
||||
*
|
||||
* `analyze` holds the whole `KnowledgeGraph` on the main thread for the entire
|
||||
* pipeline, so peak heap is O(repo) — ~2.1 KB/node at Linux-kernel scale
|
||||
* (#2649). Measurement on a kernel-shaped synthetic graph (400k nodes,
|
||||
* 2.7 edges/node) says where that goes:
|
||||
*
|
||||
* nodes only ....... 367 B/node
|
||||
* nodes + edges .... 2075 B/node <- reproduces the #2649 figure
|
||||
*
|
||||
* So **relationships are ~83% of graph heap** (~646 B/edge), and that is what
|
||||
* this sink removes. 646 B for an object holding four short strings is the cost
|
||||
* of storing every edge four times over — `relationshipMap`, a
|
||||
* `relationshipsByType` bucket, and both endpoints' `edgeIdsByNode` Sets — plus
|
||||
* an `id` that concatenates both endpoint ids. (Dropping just the two redundant
|
||||
* indexes was measured too: 174 of 648 B/edge, ~1.3x. Not enough on its own.)
|
||||
*
|
||||
* Nodes are deliberately NOT streamed: they are the other 17%, and two
|
||||
* scope-resolution index builders (`buildGraphNodeLookup`,
|
||||
* `buildGraphCallableAnchorIndex`) scan them.
|
||||
*
|
||||
* ## How much this actually saves — read this before quoting a number
|
||||
*
|
||||
* Measured A/B against the object-based graph, 400k nodes / 1.08M edges, all
|
||||
* edges streamable (the worst case for this design): **823 MB -> 626 MB, ~1.3x**,
|
||||
* with all 1.08M edges still visible through `iterRelationships`.
|
||||
*
|
||||
* That is well short of the ~2.9x a naive `0.17 + 0.83 * 0.21` retained-share
|
||||
* calculation suggests, and the gap is deliberate: this sink is *lossless*, so
|
||||
* it pays for the {@link streamedIds} dedup Set (one unique id string per
|
||||
* streamed edge) and the columns above. An earlier revision hit a bigger number
|
||||
* by disabling community detection, process extraction and the taint fixpoint —
|
||||
* which is why it could not be the default. 1.3x with nothing traded away is the
|
||||
* honest figure; if a future change needs more, the next lever is dedup keyed on
|
||||
* the interned column triple rather than on id strings (it must first be shown
|
||||
* not to alter the emitted row SET).
|
||||
*
|
||||
* ## What it costs — measured, not assumed
|
||||
*
|
||||
* Measured on the same 400k-node / 1.08M-edge graph, all edges streamable, each
|
||||
* arm running what its own consumers actually call:
|
||||
*
|
||||
* heap 819 MB -> 584 MB (1.40x better)
|
||||
* scans ~78 ms -> ~88 ms (parity, within run-to-run noise)
|
||||
*
|
||||
* Linear in both: verified at 100k/200k/400k/800k nodes, per-edge scan cost flat
|
||||
* (~13 ns both arms) and the heap ratio drifting only 1.7x -> 1.5x as interner
|
||||
* indices gain digits. No super-linear term, so a larger repo costs
|
||||
* proportionally more, not disproportionately.
|
||||
*
|
||||
* The dedup key encodes its tail SEGMENT COUNT, which measurably costs ~66 MB
|
||||
* here versus omitting it. That is not optional: without it a one-segment tail
|
||||
* `:7` and a two-segment `:7:0` collapse onto one key and an edge is silently
|
||||
* dropped (regression test in graph-emit-sink.test.ts).
|
||||
*
|
||||
* Getting there took three measured steps, because the naive version was 6.8x
|
||||
* WORSE (651 ms) — reads rebuild objects, and a real analyze performs SIX full
|
||||
* relationship scans (the pruner, community detection x2, process extraction x2,
|
||||
* and the taint fixpoint's CALLS pass):
|
||||
*
|
||||
* 1. The ~150-character synthesized `id` was built eagerly on every read — 6.5M
|
||||
* concatenations for a field no in-pipeline consumer reads. Isolating it
|
||||
* showed 436 ms of the regression. It is now a lazy prototype getter on
|
||||
* {@link StreamedRelationship}.
|
||||
* 2. Generator and iterator-protocol overhead: {@link forEachRelationship} loops
|
||||
* the columns directly, and {@link iterRelationships} reuses one
|
||||
* iterator-result record. Note a hand-rolled iterator allocating a fresh
|
||||
* `{value, done}` per edge measured WORSE (252 ms) than the generator it
|
||||
* replaced, so the obvious rewrite is not the one that shipped.
|
||||
* 3. The remaining ~90 ms was object allocation itself, irreducible while the
|
||||
* read API returns objects — so the five whole-graph scans moved to
|
||||
* `forEachRelationshipFields`, which passes the four fields they actually
|
||||
* read as primitives and allocates nothing. See
|
||||
* {@link GraphEmitSink.forEachRelationshipFields}.
|
||||
*
|
||||
* The last allocating scan is the taint fixpoint's `iterRelationshipsByType`
|
||||
* pass; it is one scan of six and accounts for the small residual. Give it a
|
||||
* by-type field variant only if a measurement says it matters.
|
||||
*
|
||||
* It is in any case NOT O(chunk) — node identity and the resolution registries
|
||||
* stay O(repo). True O(chunk) needs DB-side resolution and Leiden (#2337), at
|
||||
* which point this sink should be deleted rather than extended.
|
||||
*
|
||||
* ## Correctness contract
|
||||
*
|
||||
* Structural sibling of {@link PdgEmitSink}, and reuses its row builder
|
||||
* (`buildRelRow`), header (`REL_CSV_HEADER`), label derivation (`getNodeLabel`)
|
||||
* and `RelPairRouter` validity check, so the streamed row SET equals the
|
||||
* whole-graph emit's and the bulk COPY loads the same rows. Set-level, not
|
||||
* byte-level: rows stream in emit order and are not re-sorted under
|
||||
* `GITNEXUS_SORT_GRAPH_OUTPUT`.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import type { GraphNode, GraphRelationship, RelationshipType } from 'gitnexus-shared';
|
||||
import type { KnowledgeGraph } from '../graph/types.js';
|
||||
import { REL_CSV_HEADER, buildRelRow } from './csv-generator.js';
|
||||
import { getNodeLabel } from './rel-pair-routing.js';
|
||||
import { NODE_TABLES } from './schema.js';
|
||||
import { DEFAULT_EMIT_CHUNK_ROWS, SyncCsvWriter } from './sync-csv-writer.js';
|
||||
|
||||
/**
|
||||
* Relationship types that MUST stay in the in-memory graph because a phase
|
||||
* running while streaming is active reads them back.
|
||||
*
|
||||
* Derived from an exhaustive audit of every relationship read site under
|
||||
* `gitnexus/src/` (`iterRelationshipsByType` / `iterRelationships` /
|
||||
* `forEachRelationship` / `removeRelationship`), not from intuition — an
|
||||
* earlier draft of this list carried 14 types, 5 of which no reachable phase
|
||||
* reads. Every entry below names its reader:
|
||||
*
|
||||
* EXTENDS, IMPLEMENTS - mro-processor, scope-resolution/passes/mro,
|
||||
* receiver-bound-calls, pipeline/run.ts, cpp
|
||||
* member-lookup, and 9 language scope-resolvers
|
||||
* HAS_METHOD - mro-processor, di phase
|
||||
* HAS_PROPERTY - di phase, ruby scope-resolver, spring config-bindings
|
||||
* METHOD_OVERRIDES,
|
||||
* METHOD_IMPLEMENTS - mro-processor
|
||||
* DEFINES - local-symbol-pruner's isFileDefinesEdge test
|
||||
* INJECTS - di phase fan-out
|
||||
* ENTRY_POINT_OF - process-processor's collectExplicitEntryPointIds
|
||||
* (compiler-declared roots written pre-parse by the
|
||||
* standalone Move ingest, read back in `processes`)
|
||||
* IMPORTS - move-linker's file-import linking pass
|
||||
*
|
||||
* Deliberately NOT retained: STEP_IN_PROCESS / MEMBER_OF (written only by the
|
||||
* `processes` / `communities` phases, which the streaming flag disables),
|
||||
* TAINT_PATH / CALL_SUMMARY (their phases are likewise gated off under the
|
||||
* flag), and HANDLES_ROUTE / HANDLES_TOOL (written by `routes`/`tools`, never
|
||||
* read back mid-pipeline).
|
||||
*
|
||||
* Adding a relationship type that a phase reads back WITHOUT adding it here is
|
||||
* a silent-wrong-graph bug, not a crash — and NOTHING automated catches it.
|
||||
* The differential round-trip test cannot: `addRelationship` partitions edges
|
||||
* between the graph and the CSVs, and the union of a partition is invariant
|
||||
* under where the partition line falls, so that test stays green no matter how
|
||||
* this set is drawn. Only the read-site audit protects this invariant; re-run it
|
||||
* (grep iterRelationshipsByType / iterRelationships / forEachRelationship /
|
||||
* removeRelationship across src/) when adding a phase or a relationship type.
|
||||
*/
|
||||
export const RETAINED_REL_TYPES: ReadonlySet<RelationshipType> = new Set<RelationshipType>([
|
||||
'EXTENDS',
|
||||
'IMPLEMENTS',
|
||||
'HAS_METHOD',
|
||||
'HAS_PROPERTY',
|
||||
'METHOD_OVERRIDES',
|
||||
'METHOD_IMPLEMENTS',
|
||||
'DEFINES',
|
||||
'INJECTS',
|
||||
'ENTRY_POINT_OF',
|
||||
'IMPORTS',
|
||||
]);
|
||||
|
||||
/**
|
||||
* COPY manifest produced by {@link GraphEmitSink.finalize}.
|
||||
*
|
||||
* Only `relsByPair` — this PR does not stream node rows, so a `nodeFiles`
|
||||
* dimension would be permanently empty. Note that unlike `PdgEmitManifest`,
|
||||
* these pair keys DO collide with the whole-graph emit's (streamed `CALLS` is
|
||||
* `Function|Function`, same as retained edges), so `loadGraphToLbug` must
|
||||
* APPEND these files to the pair rather than reject them as a collision.
|
||||
*/
|
||||
export interface GraphEmitManifest {
|
||||
/** pairKey (`From|To`) -> per-pair edge CSV. */
|
||||
readonly relsByPair: Map<string, { csvPath: string; rows: number }>;
|
||||
/** Total streamed rows, for the buffer-pool size hint (#2631 path). */
|
||||
readonly totalRows: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The slice of the sink that pipeline phases drive. Declared here, next to the
|
||||
* implementation, and imported as a type by `pipeline-phases/types.ts` so the
|
||||
* phase layer depends on this narrow capability rather than on two loose
|
||||
* callbacks bolted onto the context.
|
||||
*/
|
||||
export interface GraphEmitControl {
|
||||
/** Start routing non-retained relationships to disk (see {@link GraphEmitSink.beginStreaming}). */
|
||||
beginStreaming(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A streamed edge, rebuilt for a read.
|
||||
*
|
||||
* A class, not an object literal, for two reasons that both showed up in
|
||||
* measurement. Its shape is fixed, so V8 keeps one hidden class across millions
|
||||
* of instances; and `id` is a PROTOTYPE getter, so the ~150-character
|
||||
* concatenation happens only if a caller actually reads it — which none of the
|
||||
* in-pipeline consumers do. Building it eagerly cost 436 ms of a 555 ms
|
||||
* iteration regression across the six full scans an analyze performs (measured
|
||||
* at 400k nodes / 1.08M edges); deferring it gives that back.
|
||||
*/
|
||||
class StreamedRelationship implements GraphRelationship {
|
||||
/** Constant for streamed edges — see the note on the columns about why
|
||||
* `reason` is not retained. The persisted CSV row keeps the real value. */
|
||||
readonly reason = 'streamed';
|
||||
|
||||
constructor(
|
||||
readonly sourceId: string,
|
||||
readonly targetId: string,
|
||||
readonly type: RelationshipType,
|
||||
readonly confidence: number,
|
||||
private readonly ix: number,
|
||||
) {}
|
||||
|
||||
/** Deterministic and unique — the column index disambiguates two streamed
|
||||
* edges that share (type, source, target). Lazily built; nothing in the
|
||||
* pipeline reads it. */
|
||||
get id(): string {
|
||||
return `${this.type}:${this.sourceId}->${this.targetId}#${this.ix}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown when a consumer removes a relationship that already streamed to
|
||||
* disk. Silently no-oping would let a mutating consumer (e.g. the COBOL
|
||||
* cross-program CALL resolver) corrupt the persisted graph undetected. */
|
||||
export class StreamedRelationshipRemovalError extends Error {
|
||||
constructor(relationshipId: string) {
|
||||
super(
|
||||
`Cannot remove relationship "${relationshipId}": it has already been streamed to ` +
|
||||
`CSV and cannot be recalled. A phase that removes relationships must run before ` +
|
||||
`the GraphEmitSink is installed (see the parse-boundary construction in pipeline.ts).`,
|
||||
);
|
||||
this.name = 'StreamedRelationshipRemovalError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write-routing graph façade. Construct one per analyze run at the PARSE
|
||||
* boundary — not at `createKnowledgeGraph()` — so the pre-parse phases
|
||||
* (`structure`, `springConfig`, `markdown`, `cobol`) complete their
|
||||
* read-modify-delete passes against a fully in-memory graph. Call
|
||||
* {@link finalize} once after the pipeline, before `loadGraphToLbug`.
|
||||
*/
|
||||
export class GraphEmitSink implements KnowledgeGraph, GraphEmitControl {
|
||||
private readonly validTables: Set<string>;
|
||||
private readonly relWriters = new Map<string, SyncCsvWriter>();
|
||||
/**
|
||||
* Ids of relationships already streamed. `KnowledgeGraph.addRelationship`
|
||||
* drops duplicate ids first-writer-wins, and COPY into a PK-bearing table
|
||||
* would violate on a repeat, so the sink must dedup itself — unlike
|
||||
* `PdgEmitSink`, whose emit loop guarantees per-file uniqueness upstream.
|
||||
*
|
||||
* ponytail: O(streamed-edges) id strings retained. That is ~a tenth of full
|
||||
* edge retention (the objects, both endpoint index Sets, and the type bucket
|
||||
* all go away), but it is not O(chunk). Upgrade path if it ever dominates:
|
||||
* a per-pair sorted-run dedup on disk, or hashing ids into a Bloom filter
|
||||
* with an exact fallback.
|
||||
*/
|
||||
private readonly streamedIds = new Set<string>();
|
||||
/**
|
||||
* Streamed edges, kept as parallel columns so the sink can still answer a
|
||||
* COMPLETE relationship read (see {@link iterRelationships}). Only the four
|
||||
* fields any consumer of these edges actually reads are retained —
|
||||
* `sourceId`, `targetId`, `type`, `confidence` — audited across
|
||||
* community-processor, process-processor, taint-summaries and the pruner.
|
||||
*
|
||||
* `id`, `reason` and `step` are deliberately NOT kept. Every relationship id
|
||||
* is a unique long string, and retaining ids is exactly what made an earlier
|
||||
* fully-columnar attempt LOSE to the object-based graph (measured 838 MB vs
|
||||
* 822 MB at 400k nodes / 1.08M edges). Keeping ids out of the heap is where
|
||||
* the saving comes from, so a read synthesizes a deterministic id instead —
|
||||
* safe because `buildRelRow` never persists `rel.id` and no consumer keys on
|
||||
* it (audited).
|
||||
*
|
||||
* The dropped `reason`/`step` are safe too, but for a different reason worth
|
||||
* stating: the PERSISTED row keeps their true values, because `buildRelRow` is
|
||||
* handed the original relationship on the way through. Only in-memory reads
|
||||
* see the `'streamed'` placeholder, and the in-pipeline consumers of streamed
|
||||
* edges read neither field. So e.g. the `ACCESSES reason: 'read'|'write'`
|
||||
* distinction that MCP queries rely on survives in the database. A future
|
||||
* in-pipeline consumer needing `reason` or `step` on a streamed edge must add
|
||||
* the column, not trust the placeholder.
|
||||
*
|
||||
* Node ids are interned; the strings are shared by reference with the node
|
||||
* map's, so interning adds bookkeeping, not new text.
|
||||
*/
|
||||
private readonly nodeIds = new Map<string, number>();
|
||||
private readonly nodeIdByIx: string[] = [];
|
||||
private readonly srcIx: number[] = [];
|
||||
private readonly tgtIx: number[] = [];
|
||||
private readonly relTypes: RelationshipType[] = [];
|
||||
private readonly confidences: number[] = [];
|
||||
private finalized = false;
|
||||
/**
|
||||
* Streaming is OFF until {@link beginStreaming} is called by `parse`.
|
||||
*
|
||||
* The pre-parse phases are not all write-only: `mapCobolToGraph` scans
|
||||
* `CALLS` edges and REMOVES the unresolved ones after adding resolved
|
||||
* replacements (cobol-processor.ts). If the sink streamed from
|
||||
* construction, that scan would see an empty set, no COBOL cross-program
|
||||
* call would ever resolve, and the removal would be a silent no-op. Nothing
|
||||
* before parse produces bulk edge volume, so deferring costs nothing.
|
||||
*/
|
||||
private armed = false;
|
||||
/**
|
||||
* First writer-construction failure (`fs.openSync` throwing on e.g. EMFILE).
|
||||
* It happens inside the `SyncCsvWriter` constructor before a writer object
|
||||
* exists to carry poison, so it is held at sink level and folded into the
|
||||
* {@link finalize} error check — otherwise an open failure mid-emit would be
|
||||
* swallowed by a caller's try/catch and silently drop the rest of the rows.
|
||||
*/
|
||||
private openFailure: unknown | undefined = undefined;
|
||||
|
||||
constructor(
|
||||
private readonly real: KnowledgeGraph,
|
||||
private readonly csvDir: string,
|
||||
private readonly chunkRows: number = DEFAULT_EMIT_CHUNK_ROWS,
|
||||
) {
|
||||
this.validTables = new Set<string>(NODE_TABLES as readonly string[]);
|
||||
// Own directory, distinct from the PDG sink's: PdgEmitSink wipes and
|
||||
// recreates its dir on construction and opens with O_EXCL, so a shared dir
|
||||
// would destroy the other sink's manifest on a combined --pdg run.
|
||||
fs.rmSync(csvDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(csvDir, { recursive: true });
|
||||
}
|
||||
|
||||
// ── routed writes ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Nodes are never streamed (see the file header) — always the real graph. */
|
||||
addNode(node: GraphNode): void {
|
||||
this.real.addNode(node);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start streaming. Called once, by the `parse` phase, for the reason on
|
||||
* {@link armed}.
|
||||
*/
|
||||
beginStreaming(): void {
|
||||
this.armed = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exact dedup key, built to hold no reference to the relationship id.
|
||||
*
|
||||
* An id embeds both node ids in full — ~200 characters on this repo — and the
|
||||
* only information it adds beyond `(type, source, target)` is a short trailing
|
||||
* disambiguator, e.g. `emit-references.ts` appends `:line:col` so two calls
|
||||
* between the same pair at different sites stay distinct. The endpoints are
|
||||
* already interned for the columns, so the key reuses those indices and parses
|
||||
* the tail into NUMBERS.
|
||||
*
|
||||
* Numbers matter for more than size: a key built by slicing or replacing
|
||||
* inside a long string is a V8 sliced/cons string that keeps its parent alive,
|
||||
* so the 200-character id would never be freed and the memory saving would
|
||||
* silently fail to materialize. Parsing to numbers severs that link.
|
||||
*
|
||||
* Falls back to the full id when the tail is not a numeric `:a:b` form (other
|
||||
* id shapes exist, e.g. `rel:contains:` has no tail). Correctness first: an
|
||||
* unrecognized shape is stored exactly, just without the saving.
|
||||
*/
|
||||
private dedupKey(rel: GraphRelationship, srcIx: number, tgtIx: number): string {
|
||||
const afterTarget = rel.id.lastIndexOf(rel.targetId);
|
||||
if (afterTarget >= 0) {
|
||||
const tail = rel.id.slice(afterTarget + rel.targetId.length);
|
||||
if (tail.length === 0) return `${srcIx}|${tgtIx}|${rel.type}`;
|
||||
// `:1483:6` -> two integers. Any non-numeric segment falls through.
|
||||
if (tail.charCodeAt(0) === 58 /* ':' */) {
|
||||
let a = 0;
|
||||
let b = 0;
|
||||
let seen = 0;
|
||||
let ok = true;
|
||||
for (const part of tail.slice(1).split(':')) {
|
||||
const n = Number(part);
|
||||
if (part.length === 0 || !Number.isInteger(n)) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
if (seen === 0) a = n;
|
||||
else if (seen === 1) b = n;
|
||||
else {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
seen++;
|
||||
}
|
||||
// `seen` is part of the key: without it a one-segment tail `:7` (b
|
||||
// defaults to 0) and a two-segment `:7:0` produce the same key, and the
|
||||
// second edge is silently discarded as a duplicate. Distinct ids must
|
||||
// never collapse — that is a lost relationship with no error.
|
||||
if (ok) return `${srcIx}|${tgtIx}|${rel.type}|${seen}|${a}|${b}`;
|
||||
}
|
||||
}
|
||||
return rel.id;
|
||||
}
|
||||
|
||||
private internNode(id: string): number {
|
||||
const existing = this.nodeIds.get(id);
|
||||
if (existing !== undefined) return existing;
|
||||
const ix = this.nodeIdByIx.length;
|
||||
this.nodeIdByIx.push(id);
|
||||
this.nodeIds.set(id, ix);
|
||||
return ix;
|
||||
}
|
||||
|
||||
/** Rebuild a streamed edge; its id is synthesized lazily, not stored. */
|
||||
private streamedAt(ix: number): GraphRelationship {
|
||||
return new StreamedRelationship(
|
||||
this.nodeIdByIx[this.srcIx[ix]],
|
||||
this.nodeIdByIx[this.tgtIx[ix]],
|
||||
this.relTypes[ix],
|
||||
this.confidences[ix],
|
||||
ix,
|
||||
);
|
||||
}
|
||||
|
||||
addRelationship(relationship: GraphRelationship): void {
|
||||
if (!this.armed || RETAINED_REL_TYPES.has(relationship.type)) {
|
||||
this.real.addRelationship(relationship);
|
||||
return;
|
||||
}
|
||||
// Mirror KnowledgeGraph.addRelationship's first-writer-wins dedup.
|
||||
|
||||
const fromLabel = getNodeLabel(relationship.sourceId);
|
||||
const toLabel = getNodeLabel(relationship.targetId);
|
||||
// Skip edges whose endpoint labels are not valid node tables — mirrors
|
||||
// `RelPairRouter` exactly so the streamed set matches the whole-graph set.
|
||||
if (!this.validTables.has(fromLabel) || !this.validTables.has(toLabel)) return;
|
||||
|
||||
const pairKey = `${fromLabel}|${toLabel}`;
|
||||
let writer = this.relWriters.get(pairKey);
|
||||
if (writer === undefined) {
|
||||
try {
|
||||
writer = new SyncCsvWriter(
|
||||
path.join(this.csvDir, `rel_${fromLabel}_${toLabel}.csv`),
|
||||
REL_CSV_HEADER,
|
||||
this.chunkRows,
|
||||
);
|
||||
} catch (e) {
|
||||
this.openFailure ??= e;
|
||||
throw e;
|
||||
}
|
||||
this.relWriters.set(pairKey, writer);
|
||||
}
|
||||
// Intern first so the dedup key can reuse the indices.
|
||||
const srcIx = this.internNode(relationship.sourceId);
|
||||
const tgtIx = this.internNode(relationship.targetId);
|
||||
const key = this.dedupKey(relationship, srcIx, tgtIx);
|
||||
if (this.streamedIds.has(key)) return;
|
||||
this.streamedIds.add(key);
|
||||
|
||||
writer.addRow(buildRelRow(relationship));
|
||||
this.srcIx.push(srcIx);
|
||||
this.tgtIx.push(tgtIx);
|
||||
this.relTypes.push(relationship.type);
|
||||
this.confidences.push(relationship.confidence);
|
||||
}
|
||||
|
||||
/** Flush + close every writer and return the COPY manifest. Every fd is
|
||||
* closed even when a writer is poisoned; any IO fault — an in-flight write,
|
||||
* a final-flush failure, or a writer-open failure (EMFILE) — is surfaced
|
||||
* loudly here so a disk-full / out-of-fds run never hands a truncated CSV to
|
||||
* the bulk COPY. */
|
||||
finalize(): GraphEmitManifest {
|
||||
if (this.finalized) throw new Error('GraphEmitSink.finalize() called twice');
|
||||
this.finalized = true;
|
||||
|
||||
const errors: unknown[] = [];
|
||||
if (this.openFailure !== undefined) errors.push(this.openFailure);
|
||||
|
||||
const relsByPair = new Map<string, { csvPath: string; rows: number }>();
|
||||
let totalRows = 0;
|
||||
for (const [pairKey, writer] of this.relWriters) {
|
||||
writer.close();
|
||||
if (writer.poison !== undefined) errors.push(writer.poison);
|
||||
relsByPair.set(pairKey, { csvPath: writer.csvPath, rows: writer.rows });
|
||||
totalRows += writer.rows;
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
const first = errors[0];
|
||||
throw new Error(
|
||||
`GraphEmitSink: ${errors.length} streamed CSV writer(s) hit an IO error ` +
|
||||
`(disk-full / out-of-fds) during the emit — the persisted graph would be ` +
|
||||
`truncated, so the run is failed rather than COPYing a partial CSV: ${
|
||||
first instanceof Error ? first.message : String(first)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { relsByPair, totalRows };
|
||||
}
|
||||
|
||||
/** Best-effort fd release for the error path — when the pipeline throws
|
||||
* before {@link finalize} runs, the caller's `finally` calls this so the
|
||||
* per-pair fds never leak. Idempotent with finalize via `finalized`. */
|
||||
close(): void {
|
||||
if (this.finalized) return;
|
||||
this.finalized = true;
|
||||
for (const writer of this.relWriters.values()) {
|
||||
try {
|
||||
writer.close();
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── delegated reads / retained mutations ───────────────────────────────────
|
||||
|
||||
get nodes(): GraphNode[] {
|
||||
return this.real.nodes;
|
||||
}
|
||||
get relationships(): GraphRelationship[] {
|
||||
return [...this.iterRelationships()];
|
||||
}
|
||||
iterNodes(): IterableIterator<GraphNode> {
|
||||
return this.real.iterNodes();
|
||||
}
|
||||
/**
|
||||
* Retained edges followed by the streamed ones, so every consumer sees a
|
||||
* complete graph and no phase needs to know streaming happened. This is what
|
||||
* lets streaming be the default.
|
||||
*
|
||||
* Hand-rolled rather than a generator: a generator pays per-`yield` machinery
|
||||
* on every one of millions of edges, and the pruner and process extraction
|
||||
* walk this three times per analyze.
|
||||
*/
|
||||
iterRelationships(): IterableIterator<GraphRelationship> {
|
||||
const retained = this.real.iterRelationships();
|
||||
const self = this;
|
||||
let ix = 0;
|
||||
// One reused result record. The iterator protocol lets the producer hand
|
||||
// back the same object each step — `for…of` reads `value`/`done` and drops
|
||||
// it immediately — and allocating a fresh one per edge cost more than the
|
||||
// generator it replaced.
|
||||
const result: { value: GraphRelationship | undefined; done: boolean } = {
|
||||
value: undefined,
|
||||
done: true,
|
||||
};
|
||||
const it: IterableIterator<GraphRelationship> = {
|
||||
next(): IteratorResult<GraphRelationship> {
|
||||
const fromReal = retained.next();
|
||||
if (fromReal.done !== true) {
|
||||
result.value = fromReal.value;
|
||||
result.done = false;
|
||||
return result as IteratorResult<GraphRelationship>;
|
||||
}
|
||||
if (ix < self.srcIx.length) {
|
||||
result.value = self.streamedAt(ix++);
|
||||
result.done = false;
|
||||
return result as IteratorResult<GraphRelationship>;
|
||||
}
|
||||
result.value = undefined;
|
||||
result.done = true;
|
||||
return result as IteratorResult<GraphRelationship>;
|
||||
},
|
||||
[Symbol.iterator]() {
|
||||
return it;
|
||||
},
|
||||
};
|
||||
return it;
|
||||
}
|
||||
|
||||
*iterRelationshipsByType(type: RelationshipType): IterableIterator<GraphRelationship> {
|
||||
yield* this.real.iterRelationshipsByType(type);
|
||||
if (RETAINED_REL_TYPES.has(type)) return; // never streamed — skip the scan
|
||||
for (let ix = 0; ix < this.srcIx.length; ix++) {
|
||||
if (this.relTypes[ix] === type) yield this.streamedAt(ix);
|
||||
}
|
||||
}
|
||||
forEachNode(fn: (node: GraphNode) => void): void {
|
||||
this.real.forEachNode(fn);
|
||||
}
|
||||
/**
|
||||
* The fast path: streamed edges are read straight out of the columns, so a
|
||||
* whole-graph scan allocates NOTHING. This is what keeps iteration at parity
|
||||
* with the object-based graph despite holding relationships columnar.
|
||||
*/
|
||||
forEachRelationshipFields(
|
||||
fn: (sourceId: string, targetId: string, type: RelationshipType, confidence: number) => void,
|
||||
): void {
|
||||
this.real.forEachRelationshipFields(fn);
|
||||
for (let ix = 0; ix < this.srcIx.length; ix++) {
|
||||
fn(
|
||||
this.nodeIdByIx[this.srcIx[ix]],
|
||||
this.nodeIdByIx[this.tgtIx[ix]],
|
||||
this.relTypes[ix],
|
||||
this.confidences[ix],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Direct loop rather than delegating to {@link iterRelationships}: this is
|
||||
* the form community detection uses (twice), and skipping the generator and
|
||||
* iterator protocol is measurably cheaper on a million-edge scan. */
|
||||
forEachRelationship(fn: (rel: GraphRelationship) => void): void {
|
||||
this.real.forEachRelationship(fn);
|
||||
for (let ix = 0; ix < this.srcIx.length; ix++) fn(this.streamedAt(ix));
|
||||
}
|
||||
getNode(id: string): GraphNode | undefined {
|
||||
return this.real.getNode(id);
|
||||
}
|
||||
get nodeCount(): number {
|
||||
return this.real.nodeCount;
|
||||
}
|
||||
/** Retained edges only — streamed edges are gone from the heap by design.
|
||||
* `run-analyze.ts` sizes the LadybugDB buffer pool from this, so it adds
|
||||
* the manifest's `totalRows` back in (the hint only ever shrinks the pool,
|
||||
* so under-reporting would starve the COPY at exactly the scale this
|
||||
* feature targets). */
|
||||
get relationshipCount(): number {
|
||||
return this.real.relationshipCount + this.srcIx.length;
|
||||
}
|
||||
removeNode(nodeId: string): boolean {
|
||||
return this.real.removeNode(nodeId);
|
||||
}
|
||||
removeNodesByFile(filePath: string): number {
|
||||
return this.real.removeNodesByFile(filePath);
|
||||
}
|
||||
/**
|
||||
* Deliberately conservative. The dedup Set holds compact keys derived from a
|
||||
* relationship's endpoints ({@link dedupKey}), and a bare id alone cannot be
|
||||
* turned back into one — so a streamed edge is not directly identifiable here.
|
||||
*
|
||||
* Rather than risk the silent case (returning `false` for an edge that IS on
|
||||
* disk and cannot be recalled), anything the real graph does not hold is
|
||||
* treated as possibly-streamed once streaming has begun, and fails loudly. A
|
||||
* genuinely-absent id therefore throws too, where the object-based graph would
|
||||
* return `false`; that is acceptable because the only production caller is the
|
||||
* COBOL resolver, which runs BEFORE the sink is armed and so takes the branch
|
||||
* below.
|
||||
*
|
||||
* NOTE this diverges from {@link KnowledgeGraph.removeRelationship}, which
|
||||
* returns `false` for an id it does not hold. Pinned by a test so the
|
||||
* divergence stays deliberate.
|
||||
*/
|
||||
removeRelationship(relationshipId: string): boolean {
|
||||
if (this.real.removeRelationship(relationshipId)) return true;
|
||||
if (this.srcIx.length > 0) throw new StreamedRelationshipRemovalError(relationshipId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ import {
|
|||
NodeTableName,
|
||||
} from './schema.js';
|
||||
import { streamAllCSVsToDisk, type StreamedCSVResult } from './csv-generator.js';
|
||||
import type { GraphEmitManifest } from './graph-emit-sink.js';
|
||||
import type { PdgEmitManifest } from './pdg-emit-sink.js';
|
||||
import { getNodeLabel as deriveNodeLabel, type WriteStreamFactory } from './rel-pair-routing.js';
|
||||
import { EMBEDDABLE_LABELS, type CachedEmbedding } from '../embeddings/types.js';
|
||||
|
|
@ -37,6 +38,7 @@ import {
|
|||
isDbBusyError,
|
||||
isOpenRetryExhausted,
|
||||
isWalCorruptionError,
|
||||
bufferPoolExhaustionRemedy,
|
||||
openLbugConnection,
|
||||
sleep,
|
||||
toNativeSafePath,
|
||||
|
|
@ -46,6 +48,7 @@ import {
|
|||
type LbugConnectionHandle,
|
||||
} from './lbug-config.js';
|
||||
import {
|
||||
cleanQuarantinedMissingShadowWals,
|
||||
finalizeLbugSidecarsAfterClose,
|
||||
guardWalQuarantine,
|
||||
isMissingShadowSidecarError,
|
||||
|
|
@ -55,6 +58,7 @@ import {
|
|||
quarantineWalForMissingShadow,
|
||||
renameFailureMessage,
|
||||
shadowSidecarRecoveryMessage,
|
||||
sidecarPreflightDisabled,
|
||||
} from './sidecar-recovery.js';
|
||||
|
||||
import { logger } from '../logger.js';
|
||||
|
|
@ -822,6 +826,30 @@ const doInitLbug = async (dbPath: string, readOnly: boolean = false) => {
|
|||
// -------------------------------------------------------------------------
|
||||
const releaseInitLock = await acquireInitLock(dbPath);
|
||||
try {
|
||||
// Reclaim missing-shadow WAL quarantines from a PRIOR crash (#2637).
|
||||
// LadybugDB renames an unrecoverable WAL aside as
|
||||
// `${dbPath}.wal.missing-shadow.<ts>-<rand>` (quarantineWalForMissingShadow)
|
||||
// instead of deleting it. Once quarantined it is permanently detached from
|
||||
// the live store and never reopened, so reclaiming it is safe regardless of
|
||||
// whether the main DB file exists this run — unlike the orphan-sidecar
|
||||
// cleanup below, this must NOT be gated on "main DB missing": a quarantine
|
||||
// event and a healthy main DB are independent facts. Never let a reclaim
|
||||
// failure (e.g. a transient EBUSY from an antivirus scan) block DB startup.
|
||||
if (!sidecarPreflightDisabled()) {
|
||||
try {
|
||||
const reclaimed = await cleanQuarantinedMissingShadowWals(dbPath);
|
||||
for (const file of reclaimed) {
|
||||
logger.warn(
|
||||
`GitNexus: reclaimed quarantined WAL ${path.basename(file)} from a prior crash`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`GitNexus: failed to reclaim missing-shadow WAL quarantines: ${summarizeError(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Crash-recovery cleanup: if the main DB file is missing, stale sidecars
|
||||
// from an interrupted run can block fresh opens indefinitely.
|
||||
try {
|
||||
|
|
@ -953,7 +981,14 @@ const copyNodeCSVs = async (
|
|||
const copyQuery = getCopyQuery(table, normalizeCopyPath(csvPath));
|
||||
await copyCsvWithRetry(targetConn, copyQuery, (retryErr) => {
|
||||
const retryMsg = retryErr instanceof Error ? retryErr.message : String(retryErr);
|
||||
throw new Error(`COPY failed for ${table}: ${retryMsg.slice(0, 200)}`);
|
||||
// Pool exhaustion gets a remedy (#2631): the raw binder text gives the
|
||||
// operator nothing to act on, and on non-4K-page hosts (Ascend aarch64,
|
||||
// Apple Silicon) the pool bills up to pageSize/4KiB x faster than the
|
||||
// sizing was calibrated for — name the knob and the mechanism.
|
||||
const remedy = bufferPoolExhaustionRemedy(retryMsg);
|
||||
throw new Error(
|
||||
`COPY failed for ${table}: ${retryMsg.slice(0, 200)}${remedy ? ` ${remedy}` : ''}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -984,6 +1019,15 @@ export const loadGraphToLbug = async (
|
|||
* emits none — the manifest is the sole source and there is no double-COPY.
|
||||
*/
|
||||
pdgEmitManifest?: PdgEmitManifest,
|
||||
/**
|
||||
* Streamed structural-emit manifest (#2680). Unlike {@link pdgEmitManifest},
|
||||
* these pair keys are NOT disjoint from the whole-graph emit's: a streamed
|
||||
* `CALLS` edge is `Function|Function`, exactly like the retained edges
|
||||
* `streamAllCSVsToDisk` just wrote. So these files are APPENDED as additional
|
||||
* COPY jobs for the same pair rather than merged into `relsByPair` (a Map,
|
||||
* which holds one CSV per pair and would silently drop one of them).
|
||||
*/
|
||||
graphEmitManifest?: GraphEmitManifest,
|
||||
) => {
|
||||
if (!conn) {
|
||||
throw new Error('LadybugDB not initialized. Call initLbug first.');
|
||||
|
|
@ -1123,16 +1167,32 @@ export const loadGraphToLbug = async (
|
|||
let tCopyRels = tCopyNodes;
|
||||
let tFallback = tCopyNodes;
|
||||
|
||||
const insertedRels = totalValidRels;
|
||||
// One COPY job per CSV FILE, not per label pair. The whole-graph emit writes
|
||||
// at most one file per pair, but the streamed structural manifest (#2680) can
|
||||
// contribute a second file for a pair the whole-graph emit also wrote — both
|
||||
// must load. `relsByPair` stays a one-file-per-pair Map so the PDG merge above
|
||||
// and every other consumer are untouched.
|
||||
const copyJobs: Array<{ pairKey: string; csvPath: string; rows: number }> = [];
|
||||
for (const [pairKey, meta] of relsByPair) {
|
||||
copyJobs.push({ pairKey, csvPath: meta.csvPath, rows: meta.rows });
|
||||
}
|
||||
if (graphEmitManifest) {
|
||||
for (const [pairKey, meta] of graphEmitManifest.relsByPair) {
|
||||
copyJobs.push({ pairKey, csvPath: meta.csvPath, rows: meta.rows });
|
||||
}
|
||||
}
|
||||
|
||||
const insertedRels = totalValidRels + (graphEmitManifest?.totalRows ?? 0);
|
||||
const warnings: string[] = [];
|
||||
let poolRemedyIssued = false;
|
||||
if (insertedRels > 0) {
|
||||
log(`Loading edges: ${insertedRels.toLocaleString()} across ${relsByPair.size} types`);
|
||||
log(`Loading edges: ${insertedRels.toLocaleString()} across ${copyJobs.length} CSV files`);
|
||||
|
||||
let pairIdx = 0;
|
||||
let failedPairEdges = 0;
|
||||
const failedPairCsvPaths = new Set<string>();
|
||||
|
||||
for (const [pairKey, { csvPath: pairCsvPath, rows }] of relsByPair) {
|
||||
for (const { pairKey, csvPath: pairCsvPath, rows } of copyJobs) {
|
||||
pairIdx++;
|
||||
const [fromLabel, toLabel] = pairKey.split('|');
|
||||
const normalizedPath = normalizeCopyPath(pairCsvPath);
|
||||
|
|
@ -1140,7 +1200,7 @@ export const loadGraphToLbug = async (
|
|||
const copyQuery = `COPY ${REL_TABLE_NAME} FROM "${normalizedPath}" (from="${fromLabel}", to="${toLabel}", HEADER=true, ESCAPE='"', DELIM=',', QUOTE='"', PARALLEL=false, auto_detect=false)`;
|
||||
|
||||
if (pairIdx % 5 === 0 || rows > 1000) {
|
||||
log(`Loading edges: ${pairIdx}/${relsByPair.size} types (${fromLabel} -> ${toLabel})`);
|
||||
log(`Loading edges: ${pairIdx}/${copyJobs.length} files (${fromLabel} -> ${toLabel})`);
|
||||
}
|
||||
|
||||
// Use the captured `writeConn` (not the module-level `conn`) for the rel
|
||||
|
|
@ -1151,6 +1211,17 @@ export const loadGraphToLbug = async (
|
|||
await copyCsvWithRetry(writeConn, copyQuery, (retryErr) => {
|
||||
const retryMsg = retryErr instanceof Error ? retryErr.message : String(retryErr);
|
||||
warnings.push(`${fromLabel}->${toLabel} (${rows} edges): ${retryMsg.slice(0, 80)}`);
|
||||
// One remedy per bulk load, not per pair (#2631): pool exhaustion
|
||||
// repeats for every remaining pair once it starts. logger.warn, not
|
||||
// just warnings.push — the returned warnings array has no consumer at
|
||||
// any call site, so a push alone would leave the remedy invisible
|
||||
// while the row-by-row fallback quietly degrades the load.
|
||||
const remedy = poolRemedyIssued ? undefined : bufferPoolExhaustionRemedy(retryMsg);
|
||||
if (remedy) {
|
||||
poolRemedyIssued = true;
|
||||
warnings.push(remedy);
|
||||
logger.warn(remedy);
|
||||
}
|
||||
failedPairEdges += rows;
|
||||
failedPairCsvPaths.add(pairCsvPath);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -345,18 +345,84 @@ const parseBufferPoolSize = (raw: string | undefined): number | undefined => {
|
|||
return Math.floor(parsed);
|
||||
};
|
||||
|
||||
/**
|
||||
* The buffer-manager frame size compiled into every shipped `@ladybugdb/core`
|
||||
* binary (`LBUG_PAGE_SIZE_LOG2 = 12` in the engine's CMake) — frames are 4 KiB
|
||||
* on every platform, independent of the OS page size.
|
||||
*/
|
||||
const LBUG_ASSUMED_FRAME_SIZE = 4096;
|
||||
|
||||
/**
|
||||
* How much the OS page size amplifies buffer-pool consumption (#2631).
|
||||
*
|
||||
* LadybugDB's VM region charges pool budget per DISCARD GRANULE, not per
|
||||
* frame: `discardGranuleSize = max(frameSize, osPageSize)` (vm_region.cpp),
|
||||
* `claimFrame` bills the whole granule when its first 4 KiB frame becomes
|
||||
* resident, and `releaseFrame` refunds only when the granule's LAST frame
|
||||
* leaves. On a 64 KiB-page kernel (aarch64 openEuler — Ascend hosts) that is
|
||||
* 16 frames per granule: scattered access is billed up to 16× its real bytes,
|
||||
* and whole eviction passes can evict frames yet refund nothing — which is
|
||||
* exactly the engine's "buffer pool is full and no memory could be freed"
|
||||
* throw. Apple Silicon macOS (16 KiB pages) is the same mechanism at 4×.
|
||||
*
|
||||
* So the ANALYZE-path pool sizes (the per-element estimate, the COPY-safety
|
||||
* floor, and the cap the hint is clamped against) are scaled by this ratio:
|
||||
* the budget must cover worst-case granule charging or COPY dies on non-4K
|
||||
* hosts with a pool that would be ample on x86. The hintless default
|
||||
* (defaultBufferPoolSize — MCP serve, doctor, native-check) is deliberately
|
||||
* NOT scaled: the pool is a native eager allocation committed at DB open
|
||||
* (measured — see POOL_BYTES_PER_ELEMENT below), so scaling the global
|
||||
* default would revert the #2557 OOM cap on every 16 KiB/64 KiB host. If the
|
||||
* engine ever charges per-frame (or ships page-size-matched frames), this
|
||||
* collapses back to 1 and the scaling disappears.
|
||||
*
|
||||
* Fail-safe: an undetectable page size (win32 — where the granule mechanism
|
||||
* is absent anyway — or a failed `getconf`) means ratio 1, i.e. today's
|
||||
* behavior.
|
||||
*/
|
||||
export const granuleRatio = (pageSize: number | undefined = getOsPageSize()): number => {
|
||||
if (pageSize === undefined || !Number.isFinite(pageSize)) return 1;
|
||||
return Math.max(1, Math.floor(pageSize / LBUG_ASSUMED_FRAME_SIZE));
|
||||
};
|
||||
|
||||
/**
|
||||
* Hintless pool default — MCP serve, doctor, native-check, any open without a
|
||||
* per-run hint. Deliberately UNSCALED (#2557): the pool is an eager native
|
||||
* allocation at DB open, so a page-size-scaled default would hand a
|
||||
* long-lived `gitnexus mcp` on a 16 KiB/64 KiB host up to 80% of RAM — the
|
||||
* exact OOM exposure the 2 GiB cap was added to remove.
|
||||
*/
|
||||
const defaultBufferPoolSize = (): number =>
|
||||
Math.min(DEFAULT_BUFFER_POOL_CAP, Math.max(BUFFER_POOL_FLOOR, Math.floor(os.totalmem() * 0.8)));
|
||||
|
||||
/**
|
||||
* Clamp an adaptive pool request to [ADAPTIVE_POOL_FLOOR, default]. The lower
|
||||
* bound keeps LadybugDB's COPY viable; the upper bound (defaultBufferPoolSize)
|
||||
* means the hint can only shrink the pool from today's default and can never
|
||||
* exceed the 2 GiB / 80%-RAM cap — and on a machine whose default is below the
|
||||
* COPY floor, the default wins, so the pool is never over-committed.
|
||||
* Upper bound for the ANALYZE-path (hinted) pool: the #2557 cap scaled by the
|
||||
* granule ratio, still bounded by 80% of RAM. Scaling only this bound — and
|
||||
* not defaultBufferPoolSize — is what lets the #2631 fix take effect during
|
||||
* the bulk COPY without touching hintless opens: with an unscaled cap the
|
||||
* min() below would clamp the scaled COPY floor straight back to 2 GiB.
|
||||
*/
|
||||
const clampBufferPool = (bytes: number): number =>
|
||||
Math.min(defaultBufferPoolSize(), Math.max(ADAPTIVE_POOL_FLOOR, Math.floor(bytes)));
|
||||
const scaledAnalyzePoolCap = (pageSize: number | undefined): number =>
|
||||
Math.min(
|
||||
DEFAULT_BUFFER_POOL_CAP * granuleRatio(pageSize),
|
||||
Math.max(BUFFER_POOL_FLOOR, Math.floor(os.totalmem() * 0.8)),
|
||||
);
|
||||
|
||||
/**
|
||||
* Clamp an adaptive pool request to [ADAPTIVE_POOL_FLOOR × granuleRatio,
|
||||
* scaledAnalyzePoolCap]. The lower bound keeps LadybugDB's COPY viable
|
||||
* (scaled because the granule accounting inflates consumption on non-4K
|
||||
* hosts, see granuleRatio); the upper bound means the hint can never exceed
|
||||
* the page-size-scaled #2557 cap or 80% of RAM — and on a machine whose cap
|
||||
* is below the COPY floor, the cap wins, so the pool is never over-committed.
|
||||
* On 4 KiB hosts (ratio 1) this is byte-identical to clamping against the
|
||||
* hintless default.
|
||||
*/
|
||||
const clampBufferPool = (bytes: number, pageSize: number | undefined = getOsPageSize()): number =>
|
||||
Math.min(
|
||||
scaledAnalyzePoolCap(pageSize),
|
||||
Math.max(ADAPTIVE_POOL_FLOOR * granuleRatio(pageSize), Math.floor(bytes)),
|
||||
);
|
||||
|
||||
/**
|
||||
* Buffer-pool bytes to provision per graph element (node + relationship).
|
||||
|
|
@ -377,13 +443,22 @@ const POOL_BYTES_PER_ELEMENT = 4 * 1024;
|
|||
|
||||
/**
|
||||
* Size the buffer pool to an estimated graph size (node + relationship count),
|
||||
* clamped to [ADAPTIVE_POOL_FLOOR, defaultBufferPoolSize()]. The estimate can
|
||||
* only *shrink* the pool from the default — never above the 2 GiB / 80%-RAM cap,
|
||||
* never below the COPY-safety floor — so no repo is under-sized or gets more
|
||||
* than the default it would have today.
|
||||
* clamped to [ADAPTIVE_POOL_FLOOR, scaledAnalyzePoolCap], with every term
|
||||
* scaled by granuleRatio (#2631): on non-4K hosts the engine bills pool
|
||||
* budget per OS-page-sized granule, so the same graph consumes up to
|
||||
* pageSize/4096 × the budget it needs on x86. On 4 KiB hosts the ratio is 1
|
||||
* and this is byte-identical to the pre-#2631 behavior. The estimate is never
|
||||
* above the page-size-scaled #2557 cap bounded by 80% of RAM, never below the
|
||||
* scaled COPY-safety floor; the hintless default stays unscaled.
|
||||
*
|
||||
* `pageSize` is a test seam (the pageSizeDoctorLines convention); production
|
||||
* callers omit it and get the memoized real OS page size.
|
||||
*/
|
||||
export const estimateBufferPool = (graphElementCount: number): number =>
|
||||
clampBufferPool(graphElementCount * POOL_BYTES_PER_ELEMENT);
|
||||
export const estimateBufferPool = (
|
||||
graphElementCount: number,
|
||||
pageSize: number | undefined = getOsPageSize(),
|
||||
): number =>
|
||||
clampBufferPool(graphElementCount * POOL_BYTES_PER_ELEMENT * granuleRatio(pageSize), pageSize);
|
||||
|
||||
/**
|
||||
* Optional per-run buffer-pool size hint (bytes). The analyze orchestrator sets
|
||||
|
|
@ -422,12 +497,64 @@ const resolveBufferManagerSize = (): number => {
|
|||
if (raw.trim().length > 0) {
|
||||
logger.warn(
|
||||
{ rawValue: raw, fallback: defaultBufferPoolSize() },
|
||||
`Ignoring invalid GITNEXUS_LBUG_BUFFER_POOL_SIZE=${raw}; expected integer >= 0 (bytes; 0 restores the native 80%-of-RAM default); falling back to min(2 GiB, 80% of RAM).`,
|
||||
`Ignoring invalid GITNEXUS_LBUG_BUFFER_POOL_SIZE=${raw}; expected integer >= 0 (bytes; 0 restores the native 80%-of-RAM default); falling back to the platform default pool size.`,
|
||||
);
|
||||
}
|
||||
return defaultBufferPoolSize();
|
||||
};
|
||||
|
||||
/**
|
||||
* Doctor-facing view of the pool size the next Database open would get
|
||||
* (#2631): env override > clamped hint > unscaled hintless default. Read-only;
|
||||
* doctor prints it next to the page-size lines so support triage sees the
|
||||
* sizing inputs at a glance. `0` is the pass-through sentinel for LadybugDB's
|
||||
* native 80%-of-RAM default — callers must label it, not print "0 MiB".
|
||||
*/
|
||||
export const getEffectiveBufferPoolSize = (): number => resolveBufferManagerSize();
|
||||
|
||||
/**
|
||||
* Matches the engine's buffer-pool exhaustion throw (buffer_manager.cpp:
|
||||
* "Unable to allocate memory! The buffer pool is full and no memory could be
|
||||
* freed!"). Distinct from isLbugPageSizeFrameError above, which matches the
|
||||
* madvise/frame-release failure class.
|
||||
*/
|
||||
const BUFFER_POOL_EXHAUSTION_RE = /buffer pool is full|unable to allocate memory/i;
|
||||
|
||||
const formatMiB = (bytes: number): string => `${Math.round(bytes / (1024 * 1024))} MiB`;
|
||||
|
||||
/**
|
||||
* Actionable remedy for a buffer-pool exhaustion error (#2631), or undefined
|
||||
* when `message` is not that class. Cause → consequence → remedy, the
|
||||
* diagnoseExtensionLoad convention: names the effective pool, the override
|
||||
* knob, and — on non-4K hosts — the granule amplification that makes the
|
||||
* budget exhaust early (the reporter's Ascend/aarch64 64 KiB kernel billed a
|
||||
* pool up to 16× faster than the same analyze on x86).
|
||||
*/
|
||||
export const bufferPoolExhaustionRemedy = (
|
||||
message: string,
|
||||
pageSize: number | undefined = getOsPageSize(),
|
||||
): string | undefined => {
|
||||
if (!BUFFER_POOL_EXHAUSTION_RE.test(message)) return undefined;
|
||||
const ratio = granuleRatio(pageSize);
|
||||
const pool = resolveBufferManagerSize();
|
||||
// 0 is the pass-through sentinel (GITNEXUS_LBUG_BUFFER_POOL_SIZE=0 →
|
||||
// LadybugDB's native 80%-of-RAM default) — "0 MiB" would be nonsense in the
|
||||
// very triage text this remedy exists to provide.
|
||||
const poolLabel = pool === 0 ? "LadybugDB's native 80%-of-RAM default" : formatMiB(pool);
|
||||
const pageNote =
|
||||
ratio > 1
|
||||
? ` This host's ${(pageSize ?? 0) / 1024} KiB OS page size makes the engine bill pool ` +
|
||||
`memory in ${(pageSize ?? 0) / 1024} KiB granules — up to ${ratio}× faster budget use ` +
|
||||
`than a 4 KiB-page host running the same analyze.`
|
||||
: '';
|
||||
return (
|
||||
`The LadybugDB buffer pool (${poolLabel}) was exhausted during the bulk COPY.` +
|
||||
pageNote +
|
||||
` Set GITNEXUS_LBUG_BUFFER_POOL_SIZE=<bytes> to raise it (e.g. ${4 * 1024 * 1024 * 1024}` +
|
||||
` for 4 GiB); 0 restores LadybugDB's native 80%-of-RAM default.`
|
||||
);
|
||||
};
|
||||
|
||||
/** Matches WAL corruption errors from the LadybugDB engine. */
|
||||
const WAL_CORRUPTION_RE = /corrupt(ed)?\s+wal|invalid\s+wal\s+record|wal.*corrupt|checksum.*wal/i;
|
||||
|
||||
|
|
@ -514,8 +641,12 @@ const LBUG_PAGE_COMBO_RE = /unsupported page size combination/i;
|
|||
* True when `err` looks like the LadybugDB buffer manager failing to release
|
||||
* frame memory — the failure mode of a 4 KiB page-size assumption on a
|
||||
* 16 KiB/64 KiB-page kernel (#1231). Deliberately does NOT match the
|
||||
* generic "buffer pool is full" exhaustion error, which is a sizing
|
||||
* problem, not a page-size one.
|
||||
* generic "buffer pool is full" exhaustion error: that one is handled as a
|
||||
* SIZING problem — though since #2631 we know page size drives sizing too
|
||||
* (the engine bills pool budget per OS-page-sized discard granule, so non-4K
|
||||
* hosts exhaust the same budget up to pageSize/4096× earlier; see
|
||||
* granuleRatio, which scales the pool accordingly, and
|
||||
* bufferPoolExhaustionRemedy, which explains it to the operator).
|
||||
*/
|
||||
export const isLbugPageSizeFrameError = (err: unknown): boolean => {
|
||||
if (!err) return false;
|
||||
|
|
@ -542,6 +673,16 @@ export const isPageSizeAwareLadybug = (version: string | undefined): boolean =>
|
|||
// because analyze error paths and doctor may both ask, and getconf forks.
|
||||
let cachedOsPageSize: number | null | undefined;
|
||||
|
||||
/**
|
||||
* Test seam (the `_captureLogger` convention): pin the memoized OS page size
|
||||
* so sizing tests are host-independent — without this they would silently
|
||||
* drift on 16 KiB-page Apple Silicon runners. `number` pins a value, `null`
|
||||
* pins "undetectable", `undefined` clears the memo so the next call re-probes.
|
||||
*/
|
||||
export const _setOsPageSizeForTests = (pageSize: number | null | undefined): void => {
|
||||
cachedOsPageSize = pageSize;
|
||||
};
|
||||
|
||||
/**
|
||||
* OS memory page size in bytes, or `undefined` when it cannot be determined
|
||||
* (Windows, missing getconf, sandboxed exec). Node exposes no page-size API,
|
||||
|
|
@ -580,11 +721,6 @@ export const getOsPageSize = (): number | undefined => {
|
|||
return cachedOsPageSize ?? undefined;
|
||||
};
|
||||
|
||||
/** Exported only for unit tests — clears the getconf probe cache. */
|
||||
export const _resetOsPageSizeCacheForTest = (): void => {
|
||||
cachedOsPageSize = undefined;
|
||||
};
|
||||
|
||||
type LbugModule = typeof lbug;
|
||||
|
||||
export interface LbugDatabaseOptions {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,27 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { createRequire } from 'node:module';
|
||||
import { spawnSync, type SpawnSyncReturns } from 'node:child_process';
|
||||
|
||||
/** Cap the out-of-process native load probe so a hung filesystem cannot wedge a
|
||||
* CLI startup gate (same bounding rationale as the extension probe below). */
|
||||
const NATIVE_LOAD_PROBE_TIMEOUT_MS = 15_000;
|
||||
|
||||
/**
|
||||
* Why the native check failed. A failed check is NOT necessarily a missing
|
||||
* binary — the package may be absent, the binary may be absent, or a binary that
|
||||
* is right there may fail to load (host glibc too old, truncated download).
|
||||
* Callers that render a status line must tell those apart: reporting all of them
|
||||
* as "missing" sends users to reinstall a file they already have (#2672).
|
||||
*/
|
||||
export type NativeCheckFailureKind = 'package_missing' | 'binary_missing' | 'load_failed';
|
||||
|
||||
export interface NativeCheckResult {
|
||||
ok: boolean;
|
||||
binaryPath?: string;
|
||||
message?: string;
|
||||
/** Set only when `ok` is false. */
|
||||
kind?: NativeCheckFailureKind;
|
||||
}
|
||||
|
||||
export function checkLbugNative(overridePkgDir?: string): NativeCheckResult {
|
||||
|
|
@ -21,6 +37,7 @@ export function checkLbugNative(overridePkgDir?: string): NativeCheckResult {
|
|||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'package_missing',
|
||||
message: [
|
||||
'LadybugDB package (@ladybugdb/core) is not installed.',
|
||||
'',
|
||||
|
|
@ -35,6 +52,7 @@ export function checkLbugNative(overridePkgDir?: string): NativeCheckResult {
|
|||
return {
|
||||
ok: false,
|
||||
binaryPath,
|
||||
kind: 'binary_missing',
|
||||
message: [
|
||||
'LadybugDB native binary (lbugjs.node) is missing.',
|
||||
'',
|
||||
|
|
@ -59,35 +77,168 @@ export function checkLbugNative(overridePkgDir?: string): NativeCheckResult {
|
|||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const _require = createRequire(import.meta.url);
|
||||
_require(binaryPath);
|
||||
} catch (err: unknown) {
|
||||
const nativeError = err instanceof Error ? err.message : String(err);
|
||||
// Validate loadability in a THROWAWAY CHILD PROCESS, not in-process. A merely
|
||||
// truncated or corrupted .node (valid header, missing pages) does not throw a
|
||||
// catchable error — it SIGBUSes the dynamic loader mid-dlopen, which would take
|
||||
// the whole CLI down with a raw exit 135 and no guidance (#2441). Loading it in
|
||||
// a child lets us observe that crash (a non-zero exit or a kill signal) and turn
|
||||
// it into the same actionable failure as a clean load error. The child requires
|
||||
// the binary by absolute path, exactly as the former in-process load did.
|
||||
const probe = spawnSync(process.execPath, ['-e', 'require(process.argv[1])', binaryPath], {
|
||||
encoding: 'utf8',
|
||||
timeout: NATIVE_LOAD_PROBE_TIMEOUT_MS,
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
// Run as Node even if process.execPath is an Electron/embedder binary.
|
||||
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
|
||||
});
|
||||
|
||||
// Only a child that actually RAN and failed proves the binary is bad. If the
|
||||
// probe could not run at all — a spawn error or a timeout, e.g. a sandbox that
|
||||
// forbids subprocesses or a non-Node execPath — we could not test the binary,
|
||||
// so we stay out of the way and let the command's own load be the authority
|
||||
// rather than condemn a healthy binary. (#2441 still holds: a genuinely broken
|
||||
// binary loaded in-process later still exits non-zero.)
|
||||
if (probe.error || probe.status === 0) {
|
||||
return { ok: true, binaryPath };
|
||||
}
|
||||
|
||||
// One failure class is NOT repairable by reinstalling: a host whose glibc is
|
||||
// older than the prebuilt binary requires. Every download ships the same
|
||||
// binary, so the generic advice below sends the user around a loop that always
|
||||
// ends here (#2672). Branch before it, and only here — on the arm where the
|
||||
// probe actually ran and failed, so an unrunnable probe still fails open above.
|
||||
const glibcExplanation = glibcTooOldMessage(probe.stderr ?? '');
|
||||
if (glibcExplanation !== null) {
|
||||
return {
|
||||
ok: false,
|
||||
binaryPath,
|
||||
kind: 'load_failed',
|
||||
message: [
|
||||
'LadybugDB native binary (lbugjs.node) exists but failed to load:',
|
||||
` ${nativeError}`,
|
||||
` ${describeNativeLoadFailure(probe)}`,
|
||||
'',
|
||||
'This can happen with a truncated file, ABI mismatch, or wrong-platform binary.',
|
||||
'',
|
||||
'To repair:',
|
||||
` node ${path.join(pkgDir, 'install.js')}`,
|
||||
'',
|
||||
'If install scripts were skipped (pnpm dlx / pnpx / ignore-scripts):',
|
||||
' pnpm --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter \\',
|
||||
' dlx gitnexus@latest serve',
|
||||
' pnpm add -g --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter gitnexus',
|
||||
'',
|
||||
'If using bun, add to package.json and reinstall:',
|
||||
' "trustedDependencies": ["@ladybugdb/core"]',
|
||||
glibcExplanation,
|
||||
].join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, binaryPath };
|
||||
return {
|
||||
ok: false,
|
||||
binaryPath,
|
||||
kind: 'load_failed',
|
||||
message: [
|
||||
'LadybugDB native binary (lbugjs.node) exists but failed to load:',
|
||||
` ${describeNativeLoadFailure(probe)}`,
|
||||
'',
|
||||
'This can happen with a truncated file, ABI mismatch, or wrong-platform binary.',
|
||||
'',
|
||||
'To repair:',
|
||||
` node ${path.join(pkgDir, 'install.js')}`,
|
||||
'',
|
||||
'If install scripts were skipped (pnpm dlx / pnpx / ignore-scripts):',
|
||||
' pnpm --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter \\',
|
||||
' dlx gitnexus@latest serve',
|
||||
' pnpm add -g --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter gitnexus',
|
||||
'',
|
||||
'If using bun, add to package.json and reinstall:',
|
||||
' "trustedDependencies": ["@ladybugdb/core"]',
|
||||
].join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Describe a child-observed native load failure. Reached only after a probe that
|
||||
* actually ran and failed: a fatal signal (SIGBUS/SIGSEGV ⇒ truncated/corrupt
|
||||
* binary), otherwise the child's own load error lifted from its stderr.
|
||||
*/
|
||||
function describeNativeLoadFailure(probe: SpawnSyncReturns<string>): string {
|
||||
if (probe.signal) {
|
||||
return `crashed while loading (signal ${probe.signal}) — the binary is likely truncated or corrupted`;
|
||||
}
|
||||
const lines = (probe.stderr ?? '')
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
const errorLine = lines.find((line) => /^\w*Error: /.test(line));
|
||||
return (
|
||||
errorLine?.replace(/^\w*Error:\s*/, '') ??
|
||||
lines.at(-1) ??
|
||||
`exited with code ${probe.status ?? 'unknown'}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A `GLIBC_<version>` token. The dynamic loader names the first unresolved
|
||||
* versioned symbol as ``version `GLIBC_2.34' not found (required by …)``, but we
|
||||
* key on the token plus a "not found" line rather than on glibc's exact
|
||||
* backtick/apostrophe quoting: if that wording ever changes, this degrades to
|
||||
* the generic failure message instead of misfiring.
|
||||
*/
|
||||
const GLIBC_VERSION_TOKEN = /GLIBC_(\d+(?:\.\d+)+)/g;
|
||||
|
||||
/** Numeric dotted-segment order — glibc 2.9 is OLDER than 2.34, not newer. */
|
||||
function compareDottedVersions(a: string, b: string): number {
|
||||
const left = a.split('.').map((part) => Number.parseInt(part, 10));
|
||||
const right = b.split('.').map((part) => Number.parseInt(part, 10));
|
||||
for (let i = 0; i < Math.max(left.length, right.length); i += 1) {
|
||||
const diff = (left[i] ?? 0) - (right[i] ?? 0);
|
||||
if (diff !== 0) return diff;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* This host's runtime glibc, or null when Node cannot report it (musl builds,
|
||||
* embedders without `process.report`). Read locally rather than through
|
||||
* analyzer-identity's `detectLibcVariant`: that module is deliberately reached
|
||||
* via dynamic import from the CLI lazy actions, and this file is the
|
||||
* dependency-light startup gate that must not pull it in.
|
||||
*/
|
||||
function hostGlibcVersion(): string | null {
|
||||
try {
|
||||
const report = process.report?.getReport() as
|
||||
| { header?: { glibcVersionRuntime?: unknown } }
|
||||
| undefined;
|
||||
const runtime = report?.header?.glibcVersionRuntime;
|
||||
return typeof runtime === 'string' && runtime.length > 0 ? runtime : null;
|
||||
} catch {
|
||||
// Report generation is optional on some embedded Node builds; an unknown
|
||||
// host version still leaves the required version worth printing.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Explain a glibc-too-old native load failure, or null when the probe's stderr
|
||||
* describes something else.
|
||||
*
|
||||
* Reinstalling cannot fix this class — the package ships one prebuilt binary per
|
||||
* platform — so the caller must NOT fall through to the reinstall instructions
|
||||
* (#2672). Exported for direct unit testing: a real `GLIBC_2.34' not found`
|
||||
* cannot be provoked on a host whose glibc is new enough to run the tests.
|
||||
*/
|
||||
export function glibcTooOldMessage(stderr: string): string | null {
|
||||
const required = stderr
|
||||
.split('\n')
|
||||
.filter((line) => /not found/i.test(line))
|
||||
.flatMap((line) => [...line.matchAll(GLIBC_VERSION_TOKEN)].map((match) => match[1]))
|
||||
.sort(compareDottedVersions)
|
||||
.at(-1);
|
||||
if (required === undefined) return null;
|
||||
|
||||
const host = hostGlibcVersion();
|
||||
return [
|
||||
"This host's C library (glibc) is older than the prebuilt binary requires.",
|
||||
` required: glibc ${required} or newer`,
|
||||
` this host: ${host === null ? 'glibc version could not be determined' : `glibc ${host}`}`,
|
||||
'',
|
||||
'Reinstalling will NOT help — every download ships the same prebuilt binary.',
|
||||
'',
|
||||
'Options:',
|
||||
` - Run GitNexus on a distribution with glibc ${required} or newer`,
|
||||
' (Ubuntu 22.04+, RHEL/Rocky/Alma 9+, Debian 12+, Fedora 35+).',
|
||||
' - Or use the GitNexus container image, which bundles a current glibc.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export interface FtsProbeResult {
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ import {
|
|||
buildRelRow,
|
||||
} from './csv-generator.js';
|
||||
import { getNodeLabel } from './rel-pair-routing.js';
|
||||
import { DEFAULT_EMIT_CHUNK_ROWS, SyncCsvWriter } from './sync-csv-writer.js';
|
||||
import { NODE_TABLES, type NodeTableName } from './schema.js';
|
||||
|
||||
/**
|
||||
|
|
@ -73,103 +74,9 @@ const PDG_EDGE_TYPES: ReadonlySet<RelationshipType> = new Set<RelationshipType>(
|
|||
]);
|
||||
|
||||
/** Default streamed-write buffer (rows). Matches the whole-graph emit's
|
||||
* `FLUSH_EVERY` order of magnitude; overridable via `GITNEXUS_PDG_EMIT_CHUNK_SIZE`. */
|
||||
export const DEFAULT_PDG_EMIT_CHUNK_ROWS = 500;
|
||||
|
||||
/**
|
||||
* Synchronous buffered CSV writer. Buffers up to `chunkRows` rows, then issues
|
||||
* one `fs.writeSync` straight to the OS (no in-process stream buffer). Header
|
||||
* is written into the buffer at construction and is NOT counted in `rows`
|
||||
* (matching `BufferedCSVWriter` semantics, so manifest row counts line up).
|
||||
*/
|
||||
class SyncCsvWriter {
|
||||
private fd: number;
|
||||
private buf: string[] = [];
|
||||
private readonly chunkRows: number;
|
||||
rows = 0;
|
||||
/**
|
||||
* First IO error this writer hit (a `fs.writeSync` short-write loop throwing
|
||||
* on e.g. disk-full). Once poisoned the writer refuses further rows and
|
||||
* skips its final flush; the sink surfaces it from {@link PdgEmitSink.finalize}
|
||||
* so a truncated CSV is never handed to the bulk COPY (#2202 review #4). A
|
||||
* streamed-write failure is an IO fault, not the CFG-logic error that the
|
||||
* emit loop's per-file try/catch is built to swallow — poisoning routes it
|
||||
* past that catch to a loud failure.
|
||||
*/
|
||||
poison: unknown | undefined = undefined;
|
||||
|
||||
constructor(
|
||||
readonly csvPath: string,
|
||||
header: string,
|
||||
chunkRows: number,
|
||||
) {
|
||||
// Guard a 0/negative buffer: the flush modulo would never fire and `buf`
|
||||
// would grow unbounded, defeating the whole point of streaming.
|
||||
this.chunkRows = Math.max(1, chunkRows);
|
||||
// Exclusive create (O_EXCL): the streamed-CSV dir is wiped + recreated fresh
|
||||
// by the PdgEmitSink constructor before any writer opens a file, so the path
|
||||
// never pre-exists — 'wx' both matches that invariant and refuses to follow
|
||||
// a pre-planted symlink at the path (CWE-377 / CodeQL js/insecure-temporary-file).
|
||||
this.fd = fs.openSync(csvPath, 'wx');
|
||||
this.buf.push(header);
|
||||
}
|
||||
|
||||
addRow(row: string): void {
|
||||
// A poisoned writer is dead — stop buffering so memory can't grow on a
|
||||
// writer whose fd is already in a bad state; finalize will report the fault.
|
||||
if (this.poison !== undefined) return;
|
||||
this.buf.push(row);
|
||||
this.rows++;
|
||||
// Flush on DATA-row count, not buffer length: the header occupies buf[0]
|
||||
// until the first flush, so a `buf.length >= chunkRows` test would fire one
|
||||
// row early on the first chunk. Counting rows makes every flush exactly
|
||||
// `chunkRows` rows.
|
||||
if (this.rows % this.chunkRows === 0) this.flushOrPoison();
|
||||
}
|
||||
|
||||
/** Flush, recording (and re-throwing) any IO error as poison. Re-throwing
|
||||
* lets the immediate caller log the per-file failure; the persisted `poison`
|
||||
* is the backstop that makes finalize fail loudly even when that throw is
|
||||
* swallowed by the emit loop's CFG try/catch. */
|
||||
private flushOrPoison(): void {
|
||||
try {
|
||||
this.flush();
|
||||
} catch (e) {
|
||||
this.poison ??= e;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private flush(): void {
|
||||
if (this.buf.length === 0) return;
|
||||
const data = Buffer.from(this.buf.join('\n') + '\n', 'utf8');
|
||||
// fs.writeSync can return a short byte count; loop until the whole buffer
|
||||
// lands so a partial write never truncates a CSV row mid-field.
|
||||
let offset = 0;
|
||||
while (offset < data.length) {
|
||||
offset += fs.writeSync(this.fd, data, offset, data.length - offset);
|
||||
}
|
||||
this.buf.length = 0;
|
||||
}
|
||||
|
||||
/** Flush remaining rows (unless already poisoned) and close the fd. Never
|
||||
* throws: a final-flush IO error is recorded as poison and the fd is still
|
||||
* closed, so a write error neither leaks an fd nor escapes here — the sink
|
||||
* reads {@link poison} after closing every writer and fails loudly then. */
|
||||
close(): void {
|
||||
try {
|
||||
if (this.poison === undefined) this.flush();
|
||||
} catch (e) {
|
||||
this.poison ??= e;
|
||||
} finally {
|
||||
try {
|
||||
fs.closeSync(this.fd);
|
||||
} catch {
|
||||
/* fd may already be invalid after an IO fault — nothing to recover */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
* `FLUSH_EVERY` order of magnitude; overridable via `GITNEXUS_PDG_EMIT_CHUNK_SIZE`.
|
||||
* Aliases the shared default in `sync-csv-writer.ts` (#2680 extraction). */
|
||||
export const DEFAULT_PDG_EMIT_CHUNK_ROWS = DEFAULT_EMIT_CHUNK_ROWS;
|
||||
|
||||
/**
|
||||
* COPY manifest produced by {@link PdgEmitSink.finalize}. Shaped to merge
|
||||
|
|
@ -374,6 +281,11 @@ export class PdgEmitSink implements KnowledgeGraph {
|
|||
forEachRelationship(fn: (rel: GraphRelationship) => void): void {
|
||||
this.real.forEachRelationship(fn);
|
||||
}
|
||||
forEachRelationshipFields(
|
||||
fn: (sourceId: string, targetId: string, type: RelationshipType, confidence: number) => void,
|
||||
): void {
|
||||
this.real.forEachRelationshipFields(fn);
|
||||
}
|
||||
getNode(id: string): GraphNode | undefined {
|
||||
return this.real.getNode(id);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ export const isMissingFsError = (err: unknown): boolean =>
|
|||
|
||||
const missing = isMissingFsError;
|
||||
|
||||
const sidecarPreflightDisabled = (): boolean =>
|
||||
export const sidecarPreflightDisabled = (): boolean =>
|
||||
/^(1|true|yes|on)$/i.test(process.env.GITNEXUS_DISABLE_LBUG_SIDECAR_PREFLIGHT ?? '');
|
||||
|
||||
export const statIfExists = async (filePath: string): Promise<{ size: number } | null> => {
|
||||
|
|
|
|||
110
gitnexus/src/core/lbug/sync-csv-writer.ts
Normal file
110
gitnexus/src/core/lbug/sync-csv-writer.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
/**
|
||||
* Synchronous buffered CSV writer, shared by the streaming emit sinks.
|
||||
*
|
||||
* Extracted verbatim from `pdg-emit-sink.ts` (issue #2202) so the structural
|
||||
* `GraphEmitSink` (#2680) reuses the same buffering and IO-fault discipline
|
||||
* instead of duplicating ~90 lines of it. No behaviour change: `PdgEmitSink`
|
||||
* imports this class and is otherwise untouched.
|
||||
*
|
||||
* Why synchronous? The emit loops these sinks sit under are synchronous — there
|
||||
* is no `await` point to drain an async stream, so a `WriteStream` would
|
||||
* accumulate unwritten chunks in process memory across millions of rows,
|
||||
* defeating the RSS bound this exists to provide. `fs.writeSync` goes straight
|
||||
* to the OS; resident memory is bounded to one `chunkRows` buffer. This mirrors
|
||||
* the sync-shard pattern in `storage/parsedfile-store.ts`.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
|
||||
/** Default streamed-write buffer (rows), shared by both sinks. */
|
||||
export const DEFAULT_EMIT_CHUNK_ROWS = 500;
|
||||
|
||||
export class SyncCsvWriter {
|
||||
private fd: number;
|
||||
private buf: string[] = [];
|
||||
private readonly chunkRows: number;
|
||||
rows = 0;
|
||||
/**
|
||||
* First IO error this writer hit (a `fs.writeSync` short-write loop throwing
|
||||
* on e.g. disk-full). Once poisoned the writer refuses further rows and
|
||||
* skips its final flush; the owning sink surfaces it from its `finalize()`
|
||||
* so a truncated CSV is never handed to the bulk COPY (#2202 review #4). A
|
||||
* streamed-write failure is an IO fault, not the logic error that the emit
|
||||
* loops' per-file try/catch is built to swallow — poisoning routes it past
|
||||
* that catch to a loud failure.
|
||||
*/
|
||||
poison: unknown | undefined = undefined;
|
||||
|
||||
constructor(
|
||||
readonly csvPath: string,
|
||||
header: string,
|
||||
chunkRows: number,
|
||||
) {
|
||||
// Guard a 0/negative buffer: the flush modulo would never fire and `buf`
|
||||
// would grow unbounded, defeating the whole point of streaming.
|
||||
this.chunkRows = Math.max(1, chunkRows);
|
||||
// Exclusive create (O_EXCL): the streamed-CSV dir is wiped + recreated fresh
|
||||
// by the owning sink's constructor before any writer opens a file, so the
|
||||
// path never pre-exists — 'wx' both matches that invariant and refuses to
|
||||
// follow a pre-planted symlink at the path (CWE-377 / CodeQL
|
||||
// js/insecure-temporary-file).
|
||||
this.fd = fs.openSync(csvPath, 'wx');
|
||||
this.buf.push(header);
|
||||
}
|
||||
|
||||
addRow(row: string): void {
|
||||
// A poisoned writer is dead — stop buffering so memory can't grow on a
|
||||
// writer whose fd is already in a bad state; finalize will report the fault.
|
||||
if (this.poison !== undefined) return;
|
||||
this.buf.push(row);
|
||||
this.rows++;
|
||||
// Flush on DATA-row count, not buffer length: the header occupies buf[0]
|
||||
// until the first flush, so a `buf.length >= chunkRows` test would fire one
|
||||
// row early on the first chunk. Counting rows makes every flush exactly
|
||||
// `chunkRows` rows.
|
||||
if (this.rows % this.chunkRows === 0) this.flushOrPoison();
|
||||
}
|
||||
|
||||
/** Flush, recording (and re-throwing) any IO error as poison. Re-throwing
|
||||
* lets the immediate caller log the per-file failure; the persisted `poison`
|
||||
* is the backstop that makes finalize fail loudly even when that throw is
|
||||
* swallowed by an emit loop's try/catch. */
|
||||
private flushOrPoison(): void {
|
||||
try {
|
||||
this.flush();
|
||||
} catch (e) {
|
||||
this.poison ??= e;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private flush(): void {
|
||||
if (this.buf.length === 0) return;
|
||||
const data = Buffer.from(this.buf.join('\n') + '\n', 'utf8');
|
||||
// fs.writeSync can return a short byte count; loop until the whole buffer
|
||||
// lands so a partial write never truncates a CSV row mid-field.
|
||||
let offset = 0;
|
||||
while (offset < data.length) {
|
||||
offset += fs.writeSync(this.fd, data, offset, data.length - offset);
|
||||
}
|
||||
this.buf.length = 0;
|
||||
}
|
||||
|
||||
/** Flush remaining rows (unless already poisoned) and close the fd. Never
|
||||
* throws: a final-flush IO error is recorded as poison and the fd is still
|
||||
* closed, so a write error neither leaks an fd nor escapes here — the owning
|
||||
* sink reads {@link poison} after closing every writer and fails loudly then. */
|
||||
close(): void {
|
||||
try {
|
||||
if (this.poison === undefined) this.flush();
|
||||
} catch (e) {
|
||||
this.poison ??= e;
|
||||
} finally {
|
||||
try {
|
||||
fs.closeSync(this.fd);
|
||||
} catch {
|
||||
/* fd may already be invalid after an IO fault — nothing to recover */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -286,6 +286,34 @@ export const logger = new Proxy({} as Logger, {
|
|||
},
|
||||
}) as Logger;
|
||||
|
||||
/**
|
||||
* Env flag the analyze CLI sets while its live progress bar owns the
|
||||
* terminal (it reroutes console.warn through the bar logger; see
|
||||
* `cli/analyze.ts`).
|
||||
*/
|
||||
export const ANALYZE_PROGRESS_ACTIVE_ENV = 'GITNEXUS_ANALYZE_PROGRESS_ACTIVE';
|
||||
|
||||
/**
|
||||
* Emit an operator-facing warning without corrupting analyze's live progress
|
||||
* bar. While the bar is active, the one-line progress message goes through
|
||||
* console.warn (routed into the bar by the analyze CLI) — raw pino NDJSON
|
||||
* would corrupt the one-line display, including in the heap-respawn child
|
||||
* whose stderr is piped for crash classification. Otherwise the structured
|
||||
* Pino record is emitted, falling back to the progress message when no
|
||||
* structured form is given.
|
||||
*/
|
||||
export function warnRespectingProgressBar(
|
||||
progressMessage: string,
|
||||
structured?: { readonly fields: object; readonly message: string },
|
||||
): void {
|
||||
if (process.env[ANALYZE_PROGRESS_ACTIVE_ENV] === '1') {
|
||||
console.warn(progressMessage);
|
||||
return;
|
||||
}
|
||||
if (structured) logger.warn(structured.fields, structured.message);
|
||||
else logger.warn(progressMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape of a parsed pino record. `level`, `time`, and `msg` are always
|
||||
* present; `name` is set when emitted from a named child logger; arbitrary
|
||||
|
|
|
|||
|
|
@ -11,7 +11,9 @@
|
|||
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { retryRename } from '../storage/fs-atomic.js';
|
||||
import { acquireIndexLock } from '../storage/index-lock.js';
|
||||
import { runPipelineFromRepo } from './ingestion/pipeline.js';
|
||||
import {
|
||||
isMoveCompilerInputPath,
|
||||
|
|
@ -48,10 +50,15 @@ import {
|
|||
LbugWipeError,
|
||||
DELETE_FILES_CHUNK_SIZE,
|
||||
} from './lbug/lbug-adapter.js';
|
||||
import { estimateBufferPool, setBufferPoolSizeHint } from './lbug/lbug-config.js';
|
||||
import {
|
||||
estimateBufferPool,
|
||||
setBufferPoolSizeHint,
|
||||
resolveNativeSafeStorageDir,
|
||||
} from './lbug/lbug-config.js';
|
||||
import { escapeCypherString } from './lbug/cypher-escape.js';
|
||||
import {
|
||||
buildSearchIndexesOrDegrade,
|
||||
ftsFailureIsFatal,
|
||||
createSearchFTSIndexes,
|
||||
dropSearchFTSIndexes,
|
||||
initialiseSearchFTSStemmer,
|
||||
|
|
@ -289,6 +296,11 @@ export interface AnalyzeOptions {
|
|||
* `DEFAULT_PDG_EMIT_CHUNK_ROWS`. May also be set via
|
||||
* `GITNEXUS_PDG_EMIT_CHUNK_SIZE`. Memory-only (#2202). */
|
||||
pdgEmitChunkSize?: number;
|
||||
/** Streamed structural graph emit (#2680). Honored only on a full rebuild
|
||||
* (`force === true`). May also be enabled via `GITNEXUS_STREAM_GRAPH_EMIT`.
|
||||
* Trades community detection, process extraction and PDG taint summaries for
|
||||
* a ~2.9x reduction of in-memory graph heap. */
|
||||
streamGraphEmit?: boolean;
|
||||
/**
|
||||
* Default branch threaded into generated AGENTS.md / CLAUDE.md so the
|
||||
* regression-compare example uses the configured branch instead of a
|
||||
|
|
@ -376,6 +388,15 @@ export interface AnalyzeResult {
|
|||
* in the CLI summary — same rationale as the FTS warning (#1161).
|
||||
*/
|
||||
ingestWarnings?: readonly string[];
|
||||
/**
|
||||
* Why FTS was skipped, when `ftsSkipped` is true (#2658 review L2):
|
||||
* `extension-unavailable` (the LadybugDB FTS extension could not load — the
|
||||
* offline-first case, remedied by installing it) vs `build-failed` (the
|
||||
* extension loaded but the index build/verify failed non-fatally — remedied by
|
||||
* `--repair-fts`, not by installing the extension). Lets the CLI show the
|
||||
* correct recovery hint instead of always blaming a missing extension.
|
||||
*/
|
||||
ftsSkipReason?: 'extension-unavailable' | 'build-failed';
|
||||
/**
|
||||
* True when the index this run produced/validated is the flat workspace
|
||||
* slot (#2106 R2, inverted by #2354 to follow the checked-out branch).
|
||||
|
|
@ -591,6 +612,38 @@ export const resolveStreamPdgEmit = (options: {
|
|||
options.force === true &&
|
||||
(options.streamPdgEmit === true || parseTruthyEnv(process.env.GITNEXUS_STREAM_PDG_EMIT));
|
||||
|
||||
/**
|
||||
* Resolve whether streamed structural graph emit is on for this run (#2680).
|
||||
*
|
||||
* **On by default.** It costs nothing observable: the sink answers a complete
|
||||
* relationship read, so community detection, process extraction, the taint
|
||||
* fixpoint and the local-symbol pruner all behave exactly as they do without it
|
||||
* — the edges simply live in columns and on disk instead of as objects. There is
|
||||
* no reason to make a user opt in to using less memory.
|
||||
*
|
||||
* Two conditions still bound it:
|
||||
*
|
||||
* - `force === true`. Sound only on a full rebuild, because the incremental
|
||||
* writeback (`extractChangedSubgraph`) reads relationships back out of the
|
||||
* in-memory graph. Same gate, and same reason, as {@link resolveStreamPdgEmit}.
|
||||
* - `GITNEXUS_STREAM_GRAPH_EMIT=0` (or an explicit `streamGraphEmit: false`)
|
||||
* turns it off. The escape hatch exists for bisecting a suspected
|
||||
* streaming-related fault, not as a routine choice.
|
||||
*
|
||||
* Memory-only: not part of {@link resolvePdgConfig}, so toggling never trips
|
||||
* `pdgModeMismatch`. Read every call (not memoized) so `vi.stubEnv` works.
|
||||
*/
|
||||
export const resolveStreamGraphEmit = (options: {
|
||||
force?: boolean;
|
||||
streamGraphEmit?: boolean;
|
||||
}): boolean => {
|
||||
if (options.force !== true) return false;
|
||||
if (options.streamGraphEmit !== undefined) return options.streamGraphEmit;
|
||||
// Unset ⇒ on. Set ⇒ honour it, so `=0` / `=false` is the escape hatch.
|
||||
const raw = process.env.GITNEXUS_STREAM_GRAPH_EMIT;
|
||||
return raw === undefined || raw === '' ? true : parseTruthyEnv(raw);
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the streamed PDG-emit write-buffer size (#2202). Explicit option wins
|
||||
* over `GITNEXUS_PDG_EMIT_CHUNK_SIZE`; `undefined` ⇒ the sink's
|
||||
|
|
@ -643,34 +696,179 @@ export const pdgModeMismatch = (recorded: RepoMeta['pdg'], options: PdgOptions):
|
|||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* The storage paths + resolved branch placement a run will write to. Computed
|
||||
* once, up front, so the `runFullAnalysis` wrapper can lock the ACTUAL write
|
||||
* directory (#2658). `metaDir` — not `getStoragePaths(repoPath, options.branch)`
|
||||
* — is the lock scope: a `--branch X` that owns the flat slot resolves to the
|
||||
* flat `.gitnexus`, so scoping off the raw option would lock the wrong dir.
|
||||
*/
|
||||
interface WriteTarget {
|
||||
storagePath: string;
|
||||
repoHasGit: boolean;
|
||||
currentCommit: string;
|
||||
checkedOutBranch: string | null;
|
||||
branchLabel: string | null;
|
||||
placement: { branch?: string };
|
||||
lbugPath: string;
|
||||
metaPath: string;
|
||||
metaDir: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which storage slot this analyze writes to, including branch
|
||||
* placement (#2106/#2354). Extracted from the top of the pipeline so the lock
|
||||
* scope (`metaDir`) is known before the lock is acquired. Throws the same
|
||||
* `--branch` / checked-out mismatch error the pipeline used to throw inline, so
|
||||
* that failure still surfaces before any lock is taken.
|
||||
*/
|
||||
async function resolveWriteTarget(repoPath: string, options: AnalyzeOptions): Promise<WriteTarget> {
|
||||
// `storagePath` is ALWAYS the flat `.gitnexus` — content-addressed caches
|
||||
// (parse-cache, parsedfile-store) and kuzu-migration cleanup live there and
|
||||
// are shared across branches (#2106 KTD7).
|
||||
const { storagePath } = getStoragePaths(repoPath);
|
||||
const repoHasGit = hasGitDir(repoPath);
|
||||
const currentCommit = repoHasGit ? getCurrentCommit(repoPath) : '';
|
||||
// Normalize the auto-detected branch the same way an explicit `--branch` is
|
||||
// validated (#2106 R1): a git ref the branch-name rules forbid becomes `null`
|
||||
// → the flat slot, matching that a later `--branch <that-ref>` query would
|
||||
// also be rejected. A normal ref round-trips index-time/query-time labels.
|
||||
const checkedOutBranch = repoHasGit
|
||||
? (sanitizeDetectedBranch(getCurrentBranch(repoPath)) ?? null)
|
||||
: null;
|
||||
// Analyze indexes the working tree, not an arbitrary ref. An explicit
|
||||
// `--branch X` while a DIFFERENT branch Y is checked out would write Y's
|
||||
// content into X's slot, corrupting X (#2106). Refuse the mismatch. Detached
|
||||
// HEAD / non-git (checkedOutBranch === null) still allow an explicit label.
|
||||
if (options.branch && checkedOutBranch && options.branch !== checkedOutBranch) {
|
||||
throw new Error(
|
||||
`--branch "${options.branch}" does not match the checked-out branch "${checkedOutBranch}". ` +
|
||||
`Check out "${options.branch}" before indexing it, or omit --branch to index the current branch.`,
|
||||
);
|
||||
}
|
||||
const branchLabel = options.branch ?? checkedOutBranch;
|
||||
const placement = options.branch ? await resolveBranchPlacement(repoPath, branchLabel) : {};
|
||||
const { lbugPath, metaPath } = getStoragePaths(repoPath, placement.branch);
|
||||
return {
|
||||
storagePath,
|
||||
repoHasGit,
|
||||
currentCommit,
|
||||
checkedOutBranch,
|
||||
branchLabel,
|
||||
placement,
|
||||
lbugPath,
|
||||
metaPath,
|
||||
metaDir: path.dirname(metaPath),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* first to finish, then falls through to the normal freshness check inside —
|
||||
* so a run whose work the holder already did returns `alreadyUpToDate` in
|
||||
* seconds instead of rebuilding (single-flight coalescing), while a run for a
|
||||
* genuinely-changed tree does one follow-up incremental. No new flag: waiting
|
||||
* is the default, which is what hook-driven re-index wants.
|
||||
*
|
||||
* The lock is held by whichever process runs the pipeline (the heap-respawn
|
||||
* child, or the original) — see index-lock.ts for why ownership lives with the
|
||||
* writer, not a supervising parent. Released as soon as the write completes or
|
||||
* throws; the post-analysis steps in the CLI (skills, registry) run lock-free.
|
||||
*/
|
||||
export async function runFullAnalysis(
|
||||
repoPath: string,
|
||||
options: AnalyzeOptions,
|
||||
callbacks: AnalyzeCallbacks,
|
||||
runnerIdentityAtBootstrap?: AnalyzerRunnerIdentity,
|
||||
): Promise<AnalyzeResult> {
|
||||
// Validate operator-provided FTS config before anything else — a typo fails
|
||||
// here in ms, without taking the lock. (createSearchFTSIndexes reuses the
|
||||
// cached value via getSearchFTSStemmer.)
|
||||
initialiseSearchFTSStemmer();
|
||||
initialiseSearchFTSCjkSegmentation();
|
||||
// Scope the degraded-parse log throttle to this run (module-level counter
|
||||
// would otherwise stay saturated on a reused process).
|
||||
resetDegradedParseCounter();
|
||||
|
||||
const log = (msg: string) => callbacks.onLog?.(msg);
|
||||
const acquireOpts = {
|
||||
log,
|
||||
onWaitStart: () =>
|
||||
callbacks.onProgress('lock', 0, 'Waiting for another analyze to finish on this index…'),
|
||||
};
|
||||
|
||||
let writeTarget = await resolveWriteTarget(repoPath, options);
|
||||
let lock = await acquireIndexLock(writeTarget.metaDir, acquireOpts);
|
||||
try {
|
||||
// #2658 review H2: acquireIndexLock can wait up to the timeout ceiling,
|
||||
// during which git HEAD/branch — and thus the resolved write slot — may
|
||||
// change (a commit lands, a branch is switched, or another writer adopts the
|
||||
// flat slot). The pre-wait snapshot must NOT be reused: re-resolve UNDER the
|
||||
// lock so the freshness check (`existingMeta.lastCommit === currentCommit`)
|
||||
// and the meta stamps see current git state, honoring the module's "re-check
|
||||
// freshness after acquiring" contract. If the slot itself moved we hold the
|
||||
// WRONG lock — release and re-acquire the correct one. Bounded so a
|
||||
// pathologically churning checkout can't loop forever; after the cap we
|
||||
// proceed on the current lock. The loop is INSIDE the try so a re-resolve
|
||||
// that throws (e.g. a `--branch` that stopped matching the now-switched
|
||||
// checkout) still releases the held lock via `finally` (no leak).
|
||||
const MAX_RELOCK = 3;
|
||||
for (let attempt = 0; attempt < MAX_RELOCK; attempt++) {
|
||||
const fresh = await resolveWriteTarget(repoPath, options);
|
||||
if (fresh.metaDir === writeTarget.metaDir) {
|
||||
writeTarget = fresh; // same slot — adopt the freshly-read commit/branch/placement
|
||||
break;
|
||||
}
|
||||
log(
|
||||
`Index write target moved while waiting for the lock ` +
|
||||
`(${writeTarget.metaDir} → ${fresh.metaDir}); re-acquiring the correct slot.`,
|
||||
);
|
||||
lock.release();
|
||||
writeTarget = fresh;
|
||||
lock = await acquireIndexLock(fresh.metaDir, acquireOpts);
|
||||
if (attempt === MAX_RELOCK - 1) {
|
||||
log('Index write target still moving after repeated re-acquire; proceeding on this lock.');
|
||||
}
|
||||
}
|
||||
return await runFullAnalysisInner(
|
||||
repoPath,
|
||||
options,
|
||||
callbacks,
|
||||
writeTarget,
|
||||
runnerIdentityAtBootstrap,
|
||||
);
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function runFullAnalysisInner(
|
||||
repoPath: string,
|
||||
options: AnalyzeOptions,
|
||||
callbacks: AnalyzeCallbacks,
|
||||
writeTarget: WriteTarget,
|
||||
runnerIdentityAtBootstrap?: AnalyzerRunnerIdentity,
|
||||
): Promise<AnalyzeResult> {
|
||||
const log = (msg: string) => callbacks.onLog?.(msg);
|
||||
const progress = (phase: string, percent: number, message: string) =>
|
||||
callbacks.onProgress(phase, percent, message);
|
||||
|
||||
// Resolve + validate operator-provided FTS config once, before the expensive
|
||||
// parse/load phases. A typo fails here in ms; createSearchFTSIndexes reuses
|
||||
// the cached value via getSearchFTSStemmer.
|
||||
initialiseSearchFTSStemmer();
|
||||
initialiseSearchFTSCjkSegmentation();
|
||||
// Streamed structural emit (#2680), resolved once so the pipeline flag and the
|
||||
// CSV-dir resolution below cannot disagree.
|
||||
const streamGraphEmitActive = resolveStreamGraphEmit(options);
|
||||
|
||||
// Scope the degraded-parse log throttle to this run. On a reused process
|
||||
// (e.g. tests, or any host that calls runFullAnalysis more than once) the
|
||||
// module-level counter would otherwise stay saturated and suppress every
|
||||
// degraded-parse log after the first run. The per-parse worker holds its own
|
||||
// counter in its own module instance and is process-scoped, so no separate
|
||||
// worker-side reset is needed (see safe-parse.ts ParseTimeoutError contract).
|
||||
resetDegradedParseCounter();
|
||||
// FTS-config validation and the degraded-parse counter reset happen in the
|
||||
// `runFullAnalysis` wrapper (before the lock is taken).
|
||||
|
||||
// `storagePath` is ALWAYS the flat `.gitnexus` — content-addressed caches
|
||||
// (parse-cache, parsedfile-store) and the kuzu-migration cleanup live there
|
||||
// and are shared across branches (#2106 KTD7).
|
||||
const { storagePath } = getStoragePaths(repoPath);
|
||||
// Write target (storage paths + resolved branch placement) was computed by
|
||||
// the `runFullAnalysis` wrapper — which needs `metaDir` up front to acquire
|
||||
// the exclusive index lock BEFORE any of the freshness/write work below
|
||||
// (#2658). `storagePath` is ALWAYS the flat `.gitnexus`; `placement.branch`
|
||||
// selects a `branches/<slug>/` sub-slot only for an explicit `--branch` that
|
||||
// does not own the flat slot. See resolveWriteTarget for the full contract.
|
||||
const { storagePath, repoHasGit, currentCommit, branchLabel, placement, lbugPath, metaDir } =
|
||||
writeTarget;
|
||||
|
||||
// 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
|
||||
|
|
@ -683,44 +881,6 @@ export async function runFullAnalysis(
|
|||
log('Migrating from KuzuDB to LadybugDB — rebuilding index...');
|
||||
}
|
||||
|
||||
const repoHasGit = hasGitDir(repoPath);
|
||||
const currentCommit = repoHasGit ? getCurrentCommit(repoPath) : '';
|
||||
|
||||
// ── #2106/#2354: resolve which branch slot this run writes to ─────────
|
||||
// `branchLabel` is the branch identity recorded in meta.json (incl. the
|
||||
// flat workspace slot). `placement.branch` is undefined for the flat slot
|
||||
// (the lbug/meta paths stay byte-identical to single-branch behavior) and
|
||||
// set for a `branches/<slug>/` sub-directory. Only an explicit `--branch`
|
||||
// can route to a sub-directory; a plain analyze ALWAYS targets the flat
|
||||
// slot, which follows the checked-out working tree (#2354) — the
|
||||
// auto-detected branch (null for detached HEAD / non-git) is recorded as
|
||||
// the slot's informational label only.
|
||||
// Normalize the auto-detected branch the same way an explicit `--branch` is
|
||||
// validated (#2106 R1): a git ref the branch-name rules forbid (backtick,
|
||||
// `~ ^ : ? *`, leading `-`, `..`) becomes `null` → the flat slot, matching
|
||||
// that a later `--branch <that-ref>` query would also be rejected. A normal
|
||||
// ref passes through unchanged so index-time and query-time labels round-trip.
|
||||
const checkedOutBranch = repoHasGit
|
||||
? (sanitizeDetectedBranch(getCurrentBranch(repoPath)) ?? null)
|
||||
: null;
|
||||
// Analyze indexes the working tree, not an arbitrary ref. An explicit
|
||||
// `--branch X` while a DIFFERENT branch Y is checked out would write Y's
|
||||
// content (and Y's commit) into X's index slot, corrupting X (#2106). Refuse
|
||||
// the mismatch. Detached HEAD / non-git (checkedOutBranch === null) still
|
||||
// allow an explicit label so CI checkouts can name their snapshot.
|
||||
if (options.branch && checkedOutBranch && options.branch !== checkedOutBranch) {
|
||||
throw new Error(
|
||||
`--branch "${options.branch}" does not match the checked-out branch "${checkedOutBranch}". ` +
|
||||
`Check out "${options.branch}" before indexing it, or omit --branch to index the current branch.`,
|
||||
);
|
||||
}
|
||||
const branchLabel = options.branch ?? checkedOutBranch;
|
||||
const placement = options.branch ? await resolveBranchPlacement(repoPath, branchLabel) : {};
|
||||
const { lbugPath, metaPath } = getStoragePaths(repoPath, placement.branch);
|
||||
// metaPath now points to the metadata file (gitnexus.json) in a branch-specific directory.
|
||||
// metaDir is the directory containing the metadata file (and branch-specific DBs).
|
||||
const metaDir = path.dirname(metaPath);
|
||||
|
||||
// Keep gitnexus.json and the legacy meta.json mirror in sync (fresher
|
||||
// indexedAt wins; nothing is deleted). Best-effort: loadMeta has its own
|
||||
// legacy fallback, so a reconciliation failure (read-only mount, full disk)
|
||||
|
|
@ -1363,6 +1523,16 @@ export async function runFullAnalysis(
|
|||
// 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,
|
||||
},
|
||||
).finally(() => moveFlowClient?.shutdown());
|
||||
|
|
@ -1490,7 +1660,12 @@ export async function runFullAnalysis(
|
|||
log('atomic-incremental: live index carries orphan sidecars — using in-place writeback');
|
||||
}
|
||||
const useAtomicSwap = (isFullRebuild || atomicIncremental) && (posixSwap || windowsSwapOk);
|
||||
const buildPath = useAtomicSwap ? `${lbugPath}.new` : lbugPath;
|
||||
// #2658: a per-run staging name (was the fixed `lbug.new`). Even under the
|
||||
// single-writer lock, a unique name means a crashed run's half-built staging
|
||||
// file can never be mistaken for — or clobber — a live run's; the lock's
|
||||
// orphan sweep (sweepStagingArtifacts) reclaims stragglers on the next
|
||||
// acquire. The `.staging.` prefix is what that sweep matches.
|
||||
const buildPath = useAtomicSwap ? `${lbugPath}.staging.${randomUUID()}` : lbugPath;
|
||||
|
||||
if (isIncremental && hashDiff) {
|
||||
log(
|
||||
|
|
@ -1566,7 +1741,15 @@ export async function runFullAnalysis(
|
|||
// the pool; env override / no-hint paths are unchanged. See
|
||||
// resolveBufferManagerSize / estimateBufferPool.
|
||||
setBufferPoolSizeHint(
|
||||
estimateBufferPool(pipelineResult.graph.nodeCount + pipelineResult.graph.relationshipCount),
|
||||
estimateBufferPool(
|
||||
pipelineResult.graph.nodeCount +
|
||||
pipelineResult.graph.relationshipCount +
|
||||
// Streamed edges left the heap but still get COPYed, so they are part of
|
||||
// the real load volume (#2680). The hint only ever SHRINKS the pool, so
|
||||
// omitting them would starve the COPY at exactly the scale streaming
|
||||
// exists to serve.
|
||||
(pipelineResult.graphEmitManifest?.totalRows ?? 0),
|
||||
),
|
||||
);
|
||||
|
||||
// Full rebuild (POSIX) builds into the temp `buildPath`; incremental and
|
||||
|
|
@ -2009,6 +2192,7 @@ export async function runFullAnalysis(
|
|||
progress('lbug', pct, msg);
|
||||
},
|
||||
pipelineResult.pdgEmitManifest,
|
||||
pipelineResult.graphEmitManifest,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -2032,6 +2216,11 @@ export async function runFullAnalysis(
|
|||
// build/verify step itself fails, so capabilities.fts.status / ftsSkipped
|
||||
// stay honest even though that failure no longer aborts the whole analyze.
|
||||
let ftsReady = ftsAvailable;
|
||||
// Why FTS ended up skipped (#2658 review L2): extension-unavailable up front,
|
||||
// or build-failed in the degrade branch below.
|
||||
let ftsSkipReason: 'extension-unavailable' | 'build-failed' | undefined = ftsAvailable
|
||||
? undefined
|
||||
: 'extension-unavailable';
|
||||
if (ftsAvailable) {
|
||||
// Degrade rather than throw: createSearchFTSIndexes re-tokenizes every
|
||||
// stored row on every run, so a native tokenizer error on a single
|
||||
|
|
@ -2047,8 +2236,24 @@ export async function runFullAnalysis(
|
|||
});
|
||||
if (ftsResult.ok) {
|
||||
progress('fts', 90, 'Search indexes ready');
|
||||
} else if (ftsFailureIsFatal(ftsResult.failureClass, useAtomicSwap)) {
|
||||
// #2658: an IO/rename/checkpoint/corruption failure while building FTS
|
||||
// is a genuinely broken build on this disk — not a concurrent writer
|
||||
// (the single-writer lock rules that out). ONLY fatal on the atomic-swap
|
||||
// path: the graph was built into a throwaway staging DB, so throwing
|
||||
// before the swap abandons the staging file and leaves the previous live
|
||||
// index intact. On an in-place build the live DB is already mutated and
|
||||
// cannot be rolled back by throwing (see ftsFailureIsFatal) — those
|
||||
// degrade in the branch below instead.
|
||||
throw new Error(
|
||||
`Search index build failed with an integrity error and the analysis was aborted ` +
|
||||
`to avoid publishing a broken index: ${ftsResult.error}. The previous index is ` +
|
||||
`left intact. Re-run \`gitnexus analyze\`; if it persists, check the disk for space ` +
|
||||
`or corruption.`,
|
||||
);
|
||||
} else {
|
||||
ftsReady = false;
|
||||
ftsSkipReason = 'build-failed';
|
||||
log(
|
||||
`FTS index build failed (${ftsResult.error}) — keyword search degraded this run. ` +
|
||||
'Graph and embeddings analysis completed successfully. Run `gitnexus analyze --repair-fts` to retry.',
|
||||
|
|
@ -2723,6 +2928,7 @@ export async function runFullAnalysis(
|
|||
pipelineResult,
|
||||
ftsSkipped: !ftsReady,
|
||||
ingestWarnings: pipelineResult.standaloneIngest.ingestWarnings,
|
||||
ftsSkipReason: ftsReady ? undefined : ftsSkipReason,
|
||||
isPrimaryBranch: !placement.branch,
|
||||
};
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -193,9 +193,81 @@ export async function verifySearchFTSIndexes(
|
|||
return missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why an FTS build failed, so the caller can react correctly (#2658):
|
||||
*
|
||||
* - `capability`: the environment can't support FTS this run, or a single
|
||||
* pre-existing row can't be tokenized (#2544/#2546 "Invalid UTF-8"). The
|
||||
* graph/embeddings work is sound — degrade keyword search and keep exit 0.
|
||||
* - `integrity`: an IO / rename / checkpoint / corruption failure while
|
||||
* writing the index. With the single-writer lock (#2658) this is no longer
|
||||
* "some other analyze racing us" — it's a genuinely broken build on this
|
||||
* disk, so the run must fail loudly rather than publish a clean-looking
|
||||
* index whose search silently never worked.
|
||||
*/
|
||||
export type FtsBuildFailureClass = 'capability' | 'integrity';
|
||||
|
||||
// Checked before integrity signatures: a row-level tokenizer error that happens
|
||||
// to mention an integrity word still degrades (it isn't a broken build).
|
||||
const FTS_CAPABILITY_SIGNATURES = ['invalid utf-8', 'failed calling lower', 'tokeniz'] as const;
|
||||
// IO / durability / corruption signatures that mean the build itself broke.
|
||||
// Deliberately SPECIFIC (#2658 review L1): generic OS errors a capability/config
|
||||
// failure can also carry — bare 'no such file or directory' (ENOENT, e.g. a
|
||||
// missing FTS extension asset) and 'bad file descriptor'/'ebadf' — are NOT here,
|
||||
// so an ambiguous failure degrades (the pre-#2658 safe behavior) instead of
|
||||
// newly aborting the whole analyze. A genuine write/rename/checkpoint integrity
|
||||
// failure still matches via 'error renaming' / 'io exception' / 'checkpoint'
|
||||
// (the #2658 repro message "Error renaming … : No such file or directory" hits
|
||||
// both 'io exception' and 'error renaming').
|
||||
const FTS_INTEGRITY_SIGNATURES = [
|
||||
'io exception',
|
||||
'i/o error',
|
||||
'io error',
|
||||
'error renaming',
|
||||
'checkpoint',
|
||||
'corrupt',
|
||||
'no space',
|
||||
'enospc',
|
||||
'double free',
|
||||
'segmentation',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Classify an FTS build failure message. Defaults to `capability` (degrade) —
|
||||
* only clearly-integrity failures escalate, so the long-standing resilience to
|
||||
* row-level tokenizer errors is preserved and we never newly fail a run on an
|
||||
* unrecognised message.
|
||||
*/
|
||||
export const classifyFtsBuildError = (message: string): FtsBuildFailureClass => {
|
||||
const m = message.toLowerCase();
|
||||
if (FTS_CAPABILITY_SIGNATURES.some((s) => m.includes(s))) return 'capability';
|
||||
if (FTS_INTEGRITY_SIGNATURES.some((s) => m.includes(s))) return 'integrity';
|
||||
return 'capability';
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether an FTS build failure should ABORT the analyze (throw before publish)
|
||||
* rather than degrade to a search-less-but-queryable index (#2658).
|
||||
*
|
||||
* Only an `integrity` failure on the atomic-swap path is fatal: there the graph
|
||||
* was built into a throwaway staging DB, so throwing abandons the staging file
|
||||
* and leaves the previous live index intact. On an in-place build
|
||||
* (`useAtomicSwap === false`: incremental, Windows default) the graph DML
|
||||
* already mutated the LIVE database, so there is nothing to roll back by
|
||||
* throwing — degrading to a queryable index with FTS marked unavailable is
|
||||
* strictly better than exiting mid-finalization over a dirty, partially-indexed
|
||||
* live DB. `capability` failures always degrade.
|
||||
*/
|
||||
export const ftsFailureIsFatal = (
|
||||
failureClass: FtsBuildFailureClass | undefined,
|
||||
useAtomicSwap: boolean,
|
||||
): boolean => failureClass === 'integrity' && useAtomicSwap;
|
||||
|
||||
export interface BuildSearchIndexesResult {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
/** Present only when `ok` is false. See {@link FtsBuildFailureClass}. */
|
||||
failureClass?: FtsBuildFailureClass;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -216,10 +288,15 @@ export async function buildSearchIndexesOrDegrade(
|
|||
await createSearchFTSIndexes(options);
|
||||
const missing = await verifySearchFTSIndexes(executeQuery);
|
||||
if (missing.length > 0) {
|
||||
return { ok: false, error: `missing indexes after build: ${missing.join(', ')}` };
|
||||
// Structural incompleteness with no thrown error — treat as capability
|
||||
// (degrade), matching prior behavior; a broken *write* surfaces as a
|
||||
// thrown IO/checkpoint error below and is classified integrity there.
|
||||
const error = `missing indexes after build: ${missing.join(', ')}`;
|
||||
return { ok: false, error, failureClass: classifyFtsBuildError(error) };
|
||||
}
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
||||
const error = e instanceof Error ? e.message : String(e);
|
||||
return { ok: false, error, failureClass: classifyFtsBuildError(error) };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,7 +71,11 @@ import {
|
|||
isSupportedCjkSegmentationMode,
|
||||
MAX_CJK_SEGMENTATION_QUERY_LENGTH,
|
||||
} from '../../core/search/cjk-segmentation.js';
|
||||
import { checkStalenessAsync, checkCwdMatch } from '../../core/git-staleness.js';
|
||||
import {
|
||||
checkStalenessAsync,
|
||||
checkCwdMatch,
|
||||
type StalenessInfo,
|
||||
} from '../../core/git-staleness.js';
|
||||
import { logger } from '../../core/logger.js';
|
||||
import {
|
||||
isLocalEmbeddingRuntimeBlockerMessage,
|
||||
|
|
@ -111,6 +115,13 @@ import {
|
|||
type PdgLayerStatus,
|
||||
} from './pdg-impact.js';
|
||||
|
||||
/**
|
||||
* Candidate `type`s that label enrichment newly populates (#2687). Before that,
|
||||
* these surfaced as `''`, which several resolution gates read as "kind unknown".
|
||||
* Anything keyed on the empty string must name these explicitly.
|
||||
*/
|
||||
const VALUE_CANDIDATE_TYPES: ReadonlySet<string> = new Set(['Const', 'Variable', 'Static']);
|
||||
|
||||
/** Real source-file extensions (`.ts`, `.py`, …) from the resolver's list,
|
||||
* excluding the empty entry and the `/index.*` forms — used to decide whether
|
||||
* an `explain` target is a file path vs a (possibly dotted) symbol name. */
|
||||
|
|
@ -749,12 +760,60 @@ interface MoveResourceRow {
|
|||
fieldList: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* #2655: a tool result can carry a `staleness` field only if it is a plain
|
||||
* object that isn't an error envelope and doesn't already carry one. Raw-array
|
||||
* results (non-tabular `cypher` rows) are excluded because the CLI's `--limit`
|
||||
* and other consumers branch on `Array.isArray`, so wrapping them would break
|
||||
* that contract. Shared by `attachToolStaleness` and the dispatch site, which
|
||||
* uses it to skip the freshness `git` spawn for results that can't carry it.
|
||||
*/
|
||||
function canCarryStaleness(result: unknown): result is Record<string, unknown> {
|
||||
return (
|
||||
result !== null &&
|
||||
typeof result === 'object' &&
|
||||
!Array.isArray(result) &&
|
||||
!('error' in result) &&
|
||||
!('staleness' in result)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* #2655: attach a non-blocking `staleness` signal to a tool result when the
|
||||
* index is behind HEAD, mirroring the `list_repos` `{commitsBehind, hint}`
|
||||
* shape. Only ever ADDS a field to a carryable object result (see
|
||||
* {@link canCarryStaleness}) — it never changes an existing result's shape.
|
||||
*/
|
||||
export function attachToolStaleness(
|
||||
result: unknown,
|
||||
staleness: StalenessInfo | undefined,
|
||||
): unknown {
|
||||
if (!staleness?.isStale || !canCarryStaleness(result)) {
|
||||
return result;
|
||||
}
|
||||
return {
|
||||
...result,
|
||||
staleness: { commitsBehind: staleness.commitsBehind, hint: staleness.hint },
|
||||
};
|
||||
}
|
||||
|
||||
export class LocalBackend {
|
||||
private static readonly TOOL_STALENESS_TTL_MS = 5000;
|
||||
private repos: Map<string, RepoHandle> = new Map();
|
||||
private contextCache: Map<string, CodebaseContext> = new Map();
|
||||
private initializedRepos: Set<string> = new Set();
|
||||
private reinitPromises: Map<string, Promise<void>> = new Map();
|
||||
private lastStalenessCheck: Map<string, number> = new Map();
|
||||
// #2655: commit-behind freshness for the hot read tools. Stores the IN-FLIGHT
|
||||
// promise (not just a timestamp) so N concurrent tool calls arriving before
|
||||
// the first `git rev-list` resolves share one subprocess instead of each
|
||||
// spawning their own; the resolved value is reused for TOOL_STALENESS_TTL_MS.
|
||||
// Keyed by lbugPath (like lastStalenessCheck) — NOT repoPath — because flat
|
||||
// and branch handles for one repo share a repoPath but carry different
|
||||
// lastCommit values, so a repoPath key would serve one handle's freshness for
|
||||
// the other; lbugPath is unique per flat/branch index.
|
||||
private toolStalenessCache: Map<string, { at: number; value: Promise<StalenessInfo> }> =
|
||||
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
|
||||
|
|
@ -1101,6 +1160,7 @@ export class LocalBackend {
|
|||
if (liveLbugPaths.has(key)) continue;
|
||||
this.initializedRepos.delete(key);
|
||||
this.lastStalenessCheck.delete(key);
|
||||
this.toolStalenessCache.delete(key);
|
||||
this.lastObservedIndexedAt.delete(key);
|
||||
this.lastObservedDbIdentity.delete(key);
|
||||
this.reinitPromises.delete(key);
|
||||
|
|
@ -1768,6 +1828,60 @@ export class LocalBackend {
|
|||
|
||||
// ─── Tool Dispatch ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* #2655: attach a commits-behind freshness signal to a hot-read-tool result,
|
||||
* skipping the `git` spawn entirely for results that can't carry it (error
|
||||
* envelopes, arrays, non-objects — see {@link canCarryStaleness}) so an
|
||||
* error-returning call pays nothing.
|
||||
*/
|
||||
private async withToolStaleness(repo: RepoHandle, result: unknown): Promise<unknown> {
|
||||
if (!canCarryStaleness(result)) return result;
|
||||
// Defensive: `checkStalenessAsync` self-catches today, but a rejection here
|
||||
// must never fail the tool — degrade to no-staleness. Paired with the
|
||||
// evict-on-reject in `stalenessForTool`, a transient failure also can't
|
||||
// poison the TTL cache entry (#2655 review F1).
|
||||
const staleness = await this.stalenessForTool(repo).catch(() => undefined);
|
||||
return attachToolStaleness(result, staleness);
|
||||
}
|
||||
|
||||
/**
|
||||
* #2655: commits-behind freshness for the hot read tools, deduped per index.
|
||||
* Returns a shared in-flight promise so concurrent tool calls spawn at most
|
||||
* one `git rev-list` per index per TTL window; the resolved value is cached
|
||||
* for TOOL_STALENESS_TTL_MS. Keyed by lbugPath so flat and branch handles
|
||||
* (same repoPath, different lastCommit) don't share an entry. Non-blocking by
|
||||
* construction: `checkStalenessAsync` swallows git failures to
|
||||
* `{ isStale: false }`, so a git error never fails the tool — it just omits
|
||||
* the `staleness` field.
|
||||
*/
|
||||
private stalenessForTool(repo: RepoHandle): Promise<StalenessInfo> {
|
||||
const now = Date.now();
|
||||
const cached = this.toolStalenessCache.get(repo.lbugPath);
|
||||
if (cached && now - cached.at < LocalBackend.TOOL_STALENESS_TTL_MS) {
|
||||
return cached.value;
|
||||
}
|
||||
// Evict the entry if the check rejects so a transient failure isn't served
|
||||
// (as a permanently-rejecting promise) for the rest of the TTL window; the
|
||||
// next call then re-runs. A resolving promise is never evicted, so happy-path
|
||||
// dedup is untouched (#2655 review F1). `Promise.resolve` wraps the call so a
|
||||
// non-thenable return can't throw at this boundary — a no-op for the real
|
||||
// async `checkStalenessAsync`, robust defense-in-depth otherwise.
|
||||
const entry: { at: number; value: Promise<StalenessInfo> } = {
|
||||
at: now,
|
||||
// Only evict if THIS entry is still current — a later call may have
|
||||
// installed a fresh (resolving) entry for the same key before a slow
|
||||
// rejection lands, and that newer entry must not be dropped.
|
||||
value: Promise.resolve(checkStalenessAsync(repo.repoPath, repo.lastCommit)).catch((err) => {
|
||||
if (this.toolStalenessCache.get(repo.lbugPath) === entry) {
|
||||
this.toolStalenessCache.delete(repo.lbugPath);
|
||||
}
|
||||
throw err;
|
||||
}),
|
||||
};
|
||||
this.toolStalenessCache.set(repo.lbugPath, entry);
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
async callTool(method: string, params: any): Promise<any> {
|
||||
if (method === 'list_repos') {
|
||||
// Paginated tool surface (#2119). `listRepos()` is unchanged for internal
|
||||
|
|
@ -1810,19 +1924,19 @@ export class LocalBackend {
|
|||
|
||||
switch (method) {
|
||||
case 'query':
|
||||
return this.query(repo, p);
|
||||
return this.withToolStaleness(repo, await this.query(repo, p));
|
||||
case 'cypher': {
|
||||
const raw = await this.cypher(repo, p);
|
||||
return this.formatCypherAsMarkdown(raw);
|
||||
return this.withToolStaleness(repo, this.formatCypherAsMarkdown(raw));
|
||||
}
|
||||
case 'context':
|
||||
return this.context(repo, p);
|
||||
return this.withToolStaleness(repo, await this.context(repo, p));
|
||||
case 'explain':
|
||||
return this.explain(repo, p);
|
||||
case 'pdg_query':
|
||||
return this.pdgQuery(repo, p);
|
||||
case 'impact':
|
||||
return this.impact(repo, p as unknown as ImpactParams);
|
||||
return this.withToolStaleness(repo, await this.impact(repo, p as unknown as ImpactParams));
|
||||
case 'detect_changes':
|
||||
return this.detectChanges(repo, p);
|
||||
case 'check':
|
||||
|
|
@ -2806,10 +2920,15 @@ export class LocalBackend {
|
|||
* Patch the `type` field on candidates whose `labels(n)[0]` projection
|
||||
* came back empty — a known LadybugDB behaviour for several node types.
|
||||
*
|
||||
* Uses one scoped UNION query across the five priority labels rather
|
||||
* than per-candidate round-trips, so cost is a single DB call regardless
|
||||
* of how many candidates need enrichment. No-op when every candidate
|
||||
* already has a non-empty type.
|
||||
* Uses one scoped UNION query across the priority labels rather than
|
||||
* per-candidate round-trips, so cost is a single DB call regardless of how
|
||||
* many candidates need enrichment. No-op when every candidate already has a
|
||||
* non-empty type.
|
||||
*
|
||||
* The value labels (`Const` / `Variable` / `Static`) are included because a
|
||||
* value candidate otherwise surfaces with `kind: ""` — which reads as
|
||||
* "unknown kind" and, worse, makes the `kind` disambiguation hint unable to
|
||||
* filter it out (#2687).
|
||||
*
|
||||
* Failures are swallowed: label enrichment is an optimisation for
|
||||
* downstream scoring and #480 Class/Interface BFS seeding; if it fails
|
||||
|
|
@ -2834,6 +2953,12 @@ export class LocalBackend {
|
|||
MATCH (n:\`Method\`) WHERE n.id IN $ids RETURN n.id AS id, 'Method' AS label
|
||||
UNION ALL
|
||||
MATCH (n:\`Constructor\`) WHERE n.id IN $ids RETURN n.id AS id, 'Constructor' AS label
|
||||
UNION ALL
|
||||
MATCH (n:\`Const\`) WHERE n.id IN $ids RETURN n.id AS id, 'Const' AS label
|
||||
UNION ALL
|
||||
MATCH (n:\`Variable\`) WHERE n.id IN $ids RETURN n.id AS id, 'Variable' AS label
|
||||
UNION ALL
|
||||
MATCH (n:\`Static\`) WHERE n.id IN $ids RETURN n.id AS id, 'Static' AS label
|
||||
`,
|
||||
{ ids },
|
||||
);
|
||||
|
|
@ -3008,7 +3133,7 @@ export class LocalBackend {
|
|||
// types (notably Class), which left downstream consumers (impact's
|
||||
// Class/Interface BFS seed, the kind-priority scoring bonus) unable to
|
||||
// distinguish a Class target from "unknown kind". One scoped UNION
|
||||
// across the five priority labels patches the type in-place without
|
||||
// across the priority labels patches the type in-place without
|
||||
// per-candidate round-trips.
|
||||
await this.enrichCandidateLabels(repo, normalized);
|
||||
|
||||
|
|
@ -3020,7 +3145,15 @@ export class LocalBackend {
|
|||
// the `type === 'Constructor'` gate still correctly triggers when a
|
||||
// Class and its Constructor share the name.
|
||||
if (!hints.kind && normalized.length > 1) {
|
||||
const ambiguousType = normalized.some((s) => s.type === '' || s.type === 'Constructor');
|
||||
// A value candidate (`Const`/`Variable`/`Static`) used to reach here with
|
||||
// `type === ''`, which is what kept this gate true for a `class Foo` +
|
||||
// `const Foo` pair and let the collapse resolve it to the Class. Label
|
||||
// enrichment now fills those in (#2687), so they must be named explicitly
|
||||
// or the collapse silently stops firing and confident resolutions become
|
||||
// `ambiguous` across every resolver-backed tool.
|
||||
const ambiguousType = normalized.some(
|
||||
(s) => s.type === '' || s.type === 'Constructor' || VALUE_CANDIDATE_TYPES.has(s.type),
|
||||
);
|
||||
if (ambiguousType) {
|
||||
const candidateIds = normalized.map((s) => s.id).filter(Boolean);
|
||||
for (const label of ['Class', 'Interface']) {
|
||||
|
|
@ -5224,10 +5357,12 @@ export class LocalBackend {
|
|||
target: { name: target },
|
||||
direction,
|
||||
totalCandidates: outcome.candidates.length,
|
||||
// No single resolved symbol → impactedCount stays 0 / risk UNKNOWN
|
||||
// (UNKNOWN must never read as "safe to refactor"). No callgraph
|
||||
// fan-out runs, so there is no per-candidate blast radius here yet.
|
||||
impactedCount: 0,
|
||||
// No single resolved symbol → the blast radius is UNDETERMINED, not
|
||||
// zero. `null` (not 0) because no callgraph fan-out runs on this path,
|
||||
// so there is not even a `maxImpactedCount` to correct a numeric zero
|
||||
// against — it would be indistinguishable from a genuine "nothing
|
||||
// depends on this" (#2687).
|
||||
impactedCount: null,
|
||||
risk: 'UNKNOWN',
|
||||
...(truncated && { candidatesTruncated: true }),
|
||||
candidates: shown.map((c) => ({
|
||||
|
|
@ -5343,12 +5478,15 @@ export class LocalBackend {
|
|||
// so consumers (CLI formatter) need this to report "N of M" honestly (#2129
|
||||
// review F11; the CLI previously read the truncated array length).
|
||||
totalCandidates: outcome.candidates.length,
|
||||
// `impactedCount` stays 0 and `risk` stays UNKNOWN — there is no single
|
||||
// resolved symbol, and UNKNOWN must NOT read as "safe to refactor". The
|
||||
// real blast radius is surfaced per-candidate plus `maxImpactedCount` /
|
||||
// `maxRisk` so a real caller can never hide behind the ambiguous zero
|
||||
// (#2129).
|
||||
impactedCount: 0,
|
||||
// `impactedCount` is `null` — UNDETERMINED, not zero — and `risk` stays
|
||||
// UNKNOWN, because there is no single resolved symbol. #2129 hoisted
|
||||
// `maxImpactedCount` / `maxRisk` here so a real caller could not hide
|
||||
// behind the ambiguous zero, but the zero itself remained
|
||||
// byte-identical to a genuine "nothing depends on this": a consumer
|
||||
// testing `impactedCount === 0` still read a confident all-clear
|
||||
// without ever looking at `candidates[]`. `null` cannot be mistaken for
|
||||
// a measured zero, while `|| 0` consumers are unchanged (#2687).
|
||||
impactedCount: null,
|
||||
risk: 'UNKNOWN',
|
||||
maxImpactedCount,
|
||||
maxRisk,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
registryPathEquals,
|
||||
} from '../storage/repo-manager.js';
|
||||
import { logger } from '../core/logger.js';
|
||||
import { autoHeapCapMb } from '../core/ingestion/utils/effective-ram.js';
|
||||
import type { JobManager } from './analyze-job.js';
|
||||
import type { WorkerMessage } from './analyze-worker.js';
|
||||
|
||||
|
|
@ -151,12 +152,20 @@ export function createLaunchAnalysisWorker(deps: LaunchDeps) {
|
|||
? ['--import', pathToFileURL(_require.resolve('tsx/esm')).href]
|
||||
: [];
|
||||
|
||||
// Worker heap: 8192MB historical default, but never above what this
|
||||
// machine/container actually has (#2649 review — a fixed 8192 inside a
|
||||
// smaller cgroup limit died to the kernel with a misleading remedy).
|
||||
// GITNEXUS_SERVER_ANALYZE_HEAP_MB overrides as an absolute value.
|
||||
const envHeapMb = Number(process.env.GITNEXUS_SERVER_ANALYZE_HEAP_MB);
|
||||
const workerHeapMb =
|
||||
Number.isInteger(envHeapMb) && envHeapMb > 0 ? envHeapMb : Math.min(8192, autoHeapCapMb());
|
||||
|
||||
const forkWorker = () => {
|
||||
const currentJob = jobManager.getJob(job.id);
|
||||
if (!currentJob || currentJob.status === 'complete' || currentJob.status === 'failed') return;
|
||||
|
||||
const child = fork(workerPath, [], {
|
||||
execArgv: [...tsxHookArgs, '--max-old-space-size=8192'],
|
||||
execArgv: [...tsxHookArgs, `--max-old-space-size=${workerHeapMb}`],
|
||||
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ import type { AnalyzeOptions } from '../core/run-analyze.js';
|
|||
import type { WorkerMessage } from './analyze-worker.js';
|
||||
import type { AnalyzerRunnerIdentity } from '../storage/repo-manager.js';
|
||||
import { projectAnalyzeResultForIpc } from './analyze-worker-ipc.js';
|
||||
// Value import (instanceof): index-lock is a lightweight storage primitive
|
||||
// (node:fs/net/crypto only), so this does NOT pull in run-analyze/repo-manager.
|
||||
import { IndexLockTimeoutError } from '../storage/index-lock.js';
|
||||
|
||||
export interface WorkerAnalysisDeps {
|
||||
runFullAnalysis: typeof import('../core/run-analyze.js').runFullAnalysis;
|
||||
|
|
@ -74,7 +77,13 @@ export async function runWorkerAnalysis(
|
|||
} catch (err: unknown) {
|
||||
// Report the failure to the parent over IPC (the parent surfaces the message).
|
||||
const message = err instanceof Error ? err.message : 'Analysis failed';
|
||||
terminal = { type: 'error', message };
|
||||
// #2658 review M2: a lock-wait timeout is transient contention (another
|
||||
// analyze held the single-writer lock), not a broken build — tag it so the
|
||||
// parent can surface a retry signal instead of an opaque hard failure.
|
||||
terminal =
|
||||
err instanceof IndexLockTimeoutError
|
||||
? { type: 'error', message, code: 'index-lock-timeout', retryable: true }
|
||||
: { type: 'error', message };
|
||||
}
|
||||
|
||||
// P3 (#2264): only report if a SIGTERM cancellation hasn't already claimed the
|
||||
|
|
|
|||
|
|
@ -40,6 +40,16 @@ export interface CompleteMessage {
|
|||
export interface ErrorMessage {
|
||||
type: 'error';
|
||||
message: string;
|
||||
/**
|
||||
* Machine-readable failure code for a parent that wants to branch instead of
|
||||
* only surfacing the string. `index-lock-timeout` (#2658 review M2) means
|
||||
* another analyze held the single-writer lock past the wait ceiling — a
|
||||
* transient, retryable condition, not a broken build. Absent for a generic
|
||||
* failure.
|
||||
*/
|
||||
code?: 'index-lock-timeout';
|
||||
/** True when the failure is expected to clear on retry (e.g. lock contention). */
|
||||
retryable?: boolean;
|
||||
}
|
||||
|
||||
/** Child → parent IPC messages. Shared with the parent-side launcher. */
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { execFileSync, execSync } from 'child_process';
|
||||
import { statSync } from 'fs';
|
||||
import { statSync, existsSync } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { logger } from '../core/logger.js';
|
||||
|
||||
// Git utilities for repository detection, commit tracking, and diff analysis
|
||||
|
||||
|
|
@ -10,10 +11,13 @@ const chompGitOutput = (value: Buffer): string => value.toString().replace(/\r?\
|
|||
/**
|
||||
* True when the working tree has uncommitted changes that analyze would
|
||||
* re-index, even at a matching HEAD. Excludes the paths GitNexus writes during
|
||||
* analyze (.gitnexus/, .claude/, .cursor/, AGENTS.md, CLAUDE.md) so its own
|
||||
* output never counts as dirty (regression vs PR #1233 behavior). Conservative
|
||||
* on any git failure. Shared so `analyze`'s fast-path gate and `status`'s
|
||||
* freshness report agree on what "dirty" means.
|
||||
* analyze (.gitnexus/, .claude/, .cursor/, AGENTS.md, CLAUDE.md, and the
|
||||
* repo-local .agents/ mirror) so its own output never counts as dirty
|
||||
* (regression vs PR #1233 behavior). The entire .agents/ tree is excluded,
|
||||
* matching the .claude/ treatment, because the skill mirror writes across
|
||||
* .agents/skills/ and deeper paths. Conservative on any git failure. Shared
|
||||
* so `analyze`'s fast-path gate and `status`'s freshness report agree on what
|
||||
* "dirty" means.
|
||||
*/
|
||||
export const isWorkingTreeDirty = (repoPath: string): boolean => {
|
||||
try {
|
||||
|
|
@ -32,6 +36,8 @@ export const isWorkingTreeDirty = (repoPath: string): boolean => {
|
|||
':(exclude).cursor/**',
|
||||
':(exclude)AGENTS.md',
|
||||
':(exclude)CLAUDE.md',
|
||||
':(exclude).agents',
|
||||
':(exclude).agents/**',
|
||||
],
|
||||
{
|
||||
cwd: repoPath,
|
||||
|
|
@ -46,6 +52,123 @@ export const isWorkingTreeDirty = (repoPath: string): boolean => {
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Snapshot, per candidate file, whether it is safe for `selfCommitContextFiles`
|
||||
* to auto-commit — call this BEFORE `analyze` writes AGENTS.md/CLAUDE.md.
|
||||
* A file is safe when it does not exist yet (first-time creation, the normal
|
||||
* case) or is currently clean (`git status --porcelain` reports nothing for
|
||||
* it). A file that already has an uncommitted user edit is unsafe: without
|
||||
* this check `selfCommitContextFiles` cannot tell that edit apart from the
|
||||
* stats refresh `analyze` is about to write, and would silently sweep both
|
||||
* into one generated-looking commit. Fails closed — a git failure marks the
|
||||
* file unsafe rather than assuming it's clean. See #2639 review round 2.
|
||||
*/
|
||||
export const snapshotSelfCommitSafety = (
|
||||
repoPath: string,
|
||||
candidateFiles: string[],
|
||||
): Map<string, boolean> => {
|
||||
const safety = new Map<string, boolean>();
|
||||
for (const name of candidateFiles) {
|
||||
if (!existsSync(path.join(repoPath, name))) {
|
||||
safety.set(name, true);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const status = execFileSync('git', ['status', '--porcelain', '--', name], {
|
||||
cwd: repoPath,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
windowsHide: true,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
safety.set(name, status.trim().length === 0);
|
||||
} catch {
|
||||
safety.set(name, false);
|
||||
}
|
||||
}
|
||||
return safety;
|
||||
};
|
||||
|
||||
/**
|
||||
* Best-effort auto-commit for the AGENTS.md/CLAUDE.md files `analyze --self-commit`
|
||||
* just (re)wrote. Filters `candidateFiles` down to the ones that actually exist
|
||||
* under `repoPath` AND were marked safe by `snapshotSelfCommitSafety` — a file
|
||||
* that already had an uncommitted edit before this run is skipped (logged),
|
||||
* never swept into the generated commit. Never `git add -A`. `git status
|
||||
* --porcelain` (not `diff --quiet`) is deliberate: a first-time `analyze` run
|
||||
* creates AGENTS.md/CLAUDE.md fresh, and untracked files never show up in
|
||||
* `git diff`, only in `git status` — the same reason `isWorkingTreeDirty`
|
||||
* above uses `--porcelain`. If `git commit` fails after `git add` already
|
||||
* staged the safe files (e.g. missing git identity), the staged files are
|
||||
* reset back to unstaged so the user's index isn't silently left mutated.
|
||||
* No-ops silently (never throws) when: none of the candidate files exist or
|
||||
* are safe, none changed, or any git step fails. Must never fail the
|
||||
* surrounding `analyze` run. See #2639.
|
||||
*/
|
||||
export const selfCommitContextFiles = (
|
||||
repoPath: string,
|
||||
candidateFiles: string[],
|
||||
preRunSafety: Map<string, boolean>,
|
||||
): void => {
|
||||
const existing = candidateFiles.filter((name) => existsSync(path.join(repoPath, name)));
|
||||
if (existing.length === 0) return;
|
||||
|
||||
const safe = existing.filter((name) => preRunSafety.get(name) === true);
|
||||
const skippedDirty = existing.filter((name) => preRunSafety.get(name) !== true);
|
||||
if (skippedDirty.length > 0) {
|
||||
logger.warn(
|
||||
{ files: skippedDirty },
|
||||
'gitnexus: --self-commit skipping file(s) with uncommitted changes from before this analyze run',
|
||||
);
|
||||
}
|
||||
if (safe.length === 0) return;
|
||||
|
||||
try {
|
||||
const status = execFileSync('git', ['status', '--porcelain', '--', ...safe], {
|
||||
cwd: repoPath,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
windowsHide: true,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
if (status.trim().length === 0) return; // nothing to commit
|
||||
} catch {
|
||||
return; // git failed (not a repo, git missing, etc.) — nothing to do
|
||||
}
|
||||
|
||||
try {
|
||||
execFileSync('git', ['add', '--', ...safe], {
|
||||
cwd: repoPath,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn({ err, files: safe }, 'gitnexus: --self-commit failed to stage context files');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
execFileSync(
|
||||
'git',
|
||||
['commit', '-m', 'chore(gitnexus): refresh index stats [skip ci]', '--', ...safe],
|
||||
{ cwd: repoPath, stdio: 'ignore', windowsHide: true },
|
||||
);
|
||||
} catch (err) {
|
||||
// Commit failed after `git add` already staged `safe` (e.g. missing git
|
||||
// identity). Restore the index to its pre-add state for exactly those
|
||||
// files rather than leaving them silently staged — `analyze` reporting
|
||||
// "success" must not leave the user's index mutated.
|
||||
try {
|
||||
execFileSync('git', ['reset', '--', ...safe], {
|
||||
cwd: repoPath,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
});
|
||||
} catch {
|
||||
/* best-effort restore; nothing more we can do */
|
||||
}
|
||||
logger.warn({ err, files: safe }, 'gitnexus: --self-commit failed to commit context files');
|
||||
}
|
||||
};
|
||||
|
||||
export const isGitRepo = (repoPath: string): boolean => {
|
||||
try {
|
||||
execSync('git rev-parse --is-inside-work-tree', {
|
||||
|
|
|
|||
722
gitnexus/src/storage/index-lock.ts
Normal file
722
gitnexus/src/storage/index-lock.ts
Normal file
|
|
@ -0,0 +1,722 @@
|
|||
/**
|
||||
* Cross-process single-writer lock for a GitNexus index directory (#2658).
|
||||
*
|
||||
* `analyze` is the only writer of a `.gitnexus/` (or `branches/<slug>/`) slot,
|
||||
* but nothing stopped two `analyze` runs — e.g. two editor/agent SessionStart
|
||||
* hooks firing on the same repo at once — from wiping and rebuilding the same
|
||||
* store concurrently. They raced on `lbug` and its sidecars, wasted N× CPU
|
||||
* producing one index, and left orphaned WAL fragments (#2637). This module
|
||||
* gives the write path an exclusive, index-directory-scoped lock so a second
|
||||
* writer waits for the first instead of colliding; after acquiring, the caller
|
||||
* re-runs its normal freshness check, so a run whose work the holder already
|
||||
* did exits up-to-date rather than rebuilding (single-flight coalescing).
|
||||
*
|
||||
* Ownership lives with the process that runs the pipeline (the heap-respawn
|
||||
* child when a respawn happens, the original otherwise) — NOT a supervising
|
||||
* parent — so the entity the OS tracks for liveness is always the real writer.
|
||||
* See run-analyze.ts for the acquire site.
|
||||
*
|
||||
* TWO BACKENDS behind the {@link acquireIndexLock} seam:
|
||||
*
|
||||
* - **socket** (Windows named pipe / Linux abstract socket, via `net`) — the
|
||||
* preferred, KERNEL-OWNED lock. Holding it = holding a listening endpoint the
|
||||
* kernel binds to this process; `EADDRINUSE` therefore means a *live* holder,
|
||||
* and the kernel drops the binding the instant the holder exits for ANY reason
|
||||
* (clean exit, crash, OOM, SIGKILL). That makes it provably race-free: no
|
||||
* stale detection, no pid-reuse guess, no takeover, and — since the endpoint
|
||||
* lives outside the index dir — no filesystem write, so it works unchanged on
|
||||
* a read-only index mount. This is the same class of kernel object as the
|
||||
* Windows named mutex the issue's reporter used as an external workaround, but
|
||||
* built from Node's stdlib `net`, so it adds NO native dependency and cannot
|
||||
* break `npx gitnexus` install anywhere.
|
||||
*
|
||||
* - **file** (`O_EXCL` pidfile) — the portable fallback for macOS/BSD (no
|
||||
* abstract sockets; filesystem sockets don't release cleanly on death) and
|
||||
* for any environment where the socket backend can't bind. It uses pid-
|
||||
* liveness staleness, an atomic rename-steal reclaim, bounded malformed-file
|
||||
* handling, read-only tolerance, and a finite wait timeout (a reused pid can
|
||||
* masquerade as live where process start-time isn't verifiable, so waiting is
|
||||
* bounded rather than a hang). Its stale-takeover has an irreducible narrow
|
||||
* race — inherent to file-based advisory locks — which is precisely why the
|
||||
* socket backend is preferred; only a kernel primitive closes it.
|
||||
*
|
||||
* Scope: cross-process, same logical index dir. The file backend never steals a
|
||||
* foreign-host lock (pid liveness is meaningless across hosts); the socket
|
||||
* backend is single-host by nature. The motivating case (local hook-driven
|
||||
* re-index) is single-host. See AcquireOptions.timeoutMs for the wait ceiling.
|
||||
*/
|
||||
import {
|
||||
openSync,
|
||||
writeSync,
|
||||
closeSync,
|
||||
readFileSync,
|
||||
unlinkSync,
|
||||
renameSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
realpathSync,
|
||||
} from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { randomBytes, randomUUID, createHash } from 'node:crypto';
|
||||
|
||||
const LOCK_FILENAME = 'analyze.lock';
|
||||
const LOCK_RECORD_VERSION = 1 as const;
|
||||
|
||||
/** Base poll interval while waiting for a live holder; jittered per attempt. */
|
||||
const DEFAULT_POLL_MS = 250;
|
||||
/** How often to re-emit the "still waiting for pid N" diagnostic. */
|
||||
const DIAGNOSTIC_INTERVAL_MS = 15_000;
|
||||
/**
|
||||
* Default wait ceiling (10 min). Generous enough to sit behind a normal
|
||||
* analyze, finite so a pid-reuse ghost on a platform without start-time
|
||||
* verification can't wedge acquisition forever (see AcquireOptions.timeoutMs).
|
||||
* A repo whose analyze legitimately runs longer can raise
|
||||
* GITNEXUS_INDEX_LOCK_TIMEOUT_MS (or set it ≤ 0 for unbounded).
|
||||
*/
|
||||
const DEFAULT_TIMEOUT_MS = 600_000;
|
||||
/**
|
||||
* How long a lock file must stay unreadable (empty/partial JSON) before we
|
||||
* treat it as a crash orphan and reclaim it. Tolerates the microsecond
|
||||
* create→write→close window of a *live* owner (see acquireIndexLock), so we
|
||||
* never steal a lock that is a poll-interval away from being written. Scaled
|
||||
* off the poll interval, floored at 1s.
|
||||
*/
|
||||
const malformedGraceMs = (pollMs: number): number => Math.max(1000, pollMs * 2);
|
||||
|
||||
/**
|
||||
* On-disk lock record. `token` proves ownership on release/steal; `startTime`
|
||||
* (Linux only) defends against pid reuse; `invocationId` is a human-traceable
|
||||
* id distinct from the security-irrelevant `token`.
|
||||
*/
|
||||
export interface LockRecord {
|
||||
v: typeof LOCK_RECORD_VERSION;
|
||||
pid: number;
|
||||
hostname: string;
|
||||
/** /proc/<pid>/stat starttime (clock ticks) on Linux; null where unavailable. */
|
||||
startTime: string | null;
|
||||
token: string;
|
||||
invocationId: string;
|
||||
acquiredAt: string;
|
||||
}
|
||||
|
||||
export interface IndexLockHandle {
|
||||
/** Our own record — `invocationId` is shown to waiters as the holder id. */
|
||||
readonly record: LockRecord;
|
||||
/** Idempotent; only removes the lock file if it still carries our token. */
|
||||
release(): void;
|
||||
}
|
||||
|
||||
export interface AcquireOptions {
|
||||
log?: (msg: string) => void;
|
||||
/**
|
||||
* Give up waiting after this long (ms), throwing {@link IndexLockTimeoutError}.
|
||||
* Default: {@link DEFAULT_TIMEOUT_MS} ({@link resolveTimeoutMs}). A finite
|
||||
* default is deliberate: on platforms without process start-time verification
|
||||
* (anything but Linux — see {@link readProcStartTime}) a crashed holder whose
|
||||
* pid was reused by an unrelated long-lived process reads as a live holder and
|
||||
* would otherwise block acquisition forever. Timing out is safe — it stops
|
||||
* *waiting*, never *steals* a possibly-live holder — and names the holder so
|
||||
* the caller can retry. Override (including to unbounded, value ≤ 0) via
|
||||
* GITNEXUS_INDEX_LOCK_TIMEOUT_MS.
|
||||
*/
|
||||
timeoutMs?: number;
|
||||
/** Base poll interval (ms); jittered. Default 250. */
|
||||
pollMs?: number;
|
||||
/** Called once when we start waiting on a live holder. */
|
||||
onWaitStart?: (holder: LockRecord) => void;
|
||||
}
|
||||
|
||||
export class IndexLockTimeoutError extends Error {
|
||||
readonly holder: LockRecord;
|
||||
/**
|
||||
* Whether `holder` carries a real, identifiable owner. False on the socket
|
||||
* backend (and the file backend's malformed/vanished-lock timeouts), where the
|
||||
* holder is a placeholder (`pid -1`) — the OS socket lock exposes no owner
|
||||
* metadata (#2658 review M3). Consumers must not present `holder.pid` as a real
|
||||
* pid when this is false.
|
||||
*/
|
||||
readonly holderKnown: boolean;
|
||||
constructor(holder: LockRecord, waitedMs: number, holderKnown = true) {
|
||||
super(
|
||||
holderKnown
|
||||
? `Timed out after ${waitedMs}ms waiting for another gitnexus analyze ` +
|
||||
`(pid ${holder.pid} on ${holder.hostname}, invocation ${holder.invocationId}) ` +
|
||||
`to release the index lock.`
|
||||
: `Timed out after ${waitedMs}ms waiting for another gitnexus analyze ` +
|
||||
`(holder identity unknown) to release the index lock.`,
|
||||
);
|
||||
this.name = 'IndexLockTimeoutError';
|
||||
this.holder = holder;
|
||||
this.holderKnown = holderKnown;
|
||||
}
|
||||
}
|
||||
|
||||
const HOSTNAME = os.hostname();
|
||||
|
||||
/** Linux: field 22 of /proc/<pid>/stat (starttime). null elsewhere / on error. */
|
||||
const readProcStartTime = (pid: number): string | null => {
|
||||
if (process.platform !== 'linux') return null;
|
||||
try {
|
||||
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
|
||||
// comm (field 2) is parenthesized and may contain spaces/')' — split after
|
||||
// the last ')' so the remaining fields align to their documented numbers.
|
||||
const afterComm = stat
|
||||
.slice(stat.lastIndexOf(') ') + 2)
|
||||
.trim()
|
||||
.split(' ');
|
||||
// afterComm[0] is field 3 (state); starttime is field 22 → index 19.
|
||||
return afterComm[19] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/** true if the pid exists (signal 0). EPERM means it exists but isn't ours. */
|
||||
const pidAlive = (pid: number): boolean => {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (err) {
|
||||
return (err as NodeJS.ErrnoException).code === 'EPERM';
|
||||
}
|
||||
};
|
||||
|
||||
const buildRecord = (): LockRecord => ({
|
||||
v: LOCK_RECORD_VERSION,
|
||||
pid: process.pid,
|
||||
hostname: HOSTNAME,
|
||||
startTime: readProcStartTime(process.pid),
|
||||
token: randomBytes(16).toString('hex'),
|
||||
invocationId: randomUUID(),
|
||||
acquiredAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const readRecord = (lockPath: string): LockRecord | null => {
|
||||
try {
|
||||
const raw = readFileSync(lockPath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as Partial<LockRecord>;
|
||||
// `typeof NaN === 'number'`, so a bare number check lets NaN/0/-1/Infinity/
|
||||
// fractional pids reach process.kill (#2658 review L4): a garbled or crafted
|
||||
// lock file with `{"pid":0}` reads as a live holder and wedges a real analyze
|
||||
// for the full wait timeout. A real pid is a positive integer.
|
||||
if (!Number.isInteger(parsed.pid) || (parsed.pid as number) <= 0) return null;
|
||||
if (typeof parsed.token !== 'string') return null;
|
||||
return parsed as LockRecord;
|
||||
} catch {
|
||||
// Missing (won the race, file gone) or malformed/half-written → treat as
|
||||
// "no readable holder"; the caller retries the O_EXCL create.
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* A same-host holder is stale iff its process is gone, or (Linux) its pid is
|
||||
* alive but was reused — a different start time. A live holder is never stolen
|
||||
* on age alone (a large repo legitimately analyzes for many minutes), and a
|
||||
* foreign-host holder is never stale (its liveness is unknowable here). Where
|
||||
* start-time verification is unavailable (non-Linux), a reused pid cannot be
|
||||
* distinguished from a genuine live holder, so it is NOT stolen — the finite
|
||||
* acquire timeout is what bounds that case instead (see AcquireOptions).
|
||||
*/
|
||||
const isStale = (holder: LockRecord): boolean => {
|
||||
if (holder.hostname !== HOSTNAME) return false;
|
||||
if (!pidAlive(holder.pid)) return true;
|
||||
const now = readProcStartTime(holder.pid);
|
||||
if (holder.startTime && now && holder.startTime !== now) return true; // pid reused
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reclaim a lock file we judged reclaimable — a dead holder (`expected` = its
|
||||
* record) or a malformed/unreadable crash-orphan (`expected` = null) — moving
|
||||
* the exact inode aside in ONE `rename` syscall to a token-unique name so two
|
||||
* waiters reclaiming the same orphan can't both win (the loser's rename ENOENTs).
|
||||
*
|
||||
* CRITICAL (#2658 review): the reclaim must not act on a STALE judgment. The
|
||||
* staleness decision (`isStale` / malformed-grace) happened a few syscalls ago;
|
||||
* a live writer may have O_EXCL-created its own lock at `lockPath` since. Blindly
|
||||
* renaming that live lock aside would delete it and admit a SECOND writer — the
|
||||
* exact double-writer this lock exists to prevent (reproduced: ~18%/round under
|
||||
* 4-way reclaim contention on the file backend). So:
|
||||
* 1. re-read `lockPath` immediately BEFORE the rename and confirm it still holds
|
||||
* exactly what we judged (same token, or still-unreadable) — shrinking the
|
||||
* window to the single gap between this read and the rename;
|
||||
* 2. after the rename, confirm what we ACTUALLY moved matches the judgment; if a
|
||||
* live lock slipped into that residual gap, RESTORE it (rename back) so its
|
||||
* holder is never displaced, and lose the reclaim.
|
||||
* A concurrent creator whose fresh lock the restore overwrites is caught by the
|
||||
* acquire loop's post-write read-back verify (see acquireViaFile), so it backs
|
||||
* off rather than proceeding as a second writer.
|
||||
*
|
||||
* Returns true if we won the reclaim (caller retries the create), false if we
|
||||
* lost the race or the judgment went stale (caller re-loops and re-reads).
|
||||
*/
|
||||
const matchesJudgment = (record: LockRecord | null, expected: LockRecord | null): boolean =>
|
||||
expected === null ? record === null : record?.token === expected.token;
|
||||
|
||||
const stealLock = (lockPath: string, me: LockRecord, expected: LockRecord | null): boolean => {
|
||||
// (1) Re-verify the judgment still holds right before we move anything.
|
||||
if (!matchesJudgment(readRecord(lockPath), expected)) return false;
|
||||
if (expected === null && !existsSync(lockPath)) return false; // malformed → but now vanished
|
||||
|
||||
const aside = `${lockPath}.dead.${me.token}`;
|
||||
try {
|
||||
renameSync(lockPath, aside);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return false; // another stealer won
|
||||
throw err;
|
||||
}
|
||||
// (2) Confirm what we moved is what we judged; if a live lock slipped into the
|
||||
// read→rename gap, put it back — a live holder must never be displaced.
|
||||
if (!matchesJudgment(readRecord(aside), expected)) {
|
||||
try {
|
||||
renameSync(aside, lockPath); // restore; an overwritten concurrent creator's read-back backs it off
|
||||
} catch {
|
||||
/* slot re-taken between our move and restore — leave it; we lost the reclaim */
|
||||
}
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
unlinkSync(aside); // uniquely ours by token → safe; best-effort
|
||||
} catch {
|
||||
/* leftover .dead.<token> is inert (not analyze.lock, not swept) — harmless */
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Placeholder holder for an {@link IndexLockTimeoutError} thrown while the lock
|
||||
* file exists but no valid record can be read (malformed/partial), or it keeps
|
||||
* vanishing — there is no real holder to name, but the error still needs one so
|
||||
* the CLI's `err.holder.pid` stays defined. This path is a rare backstop:
|
||||
* malformed files are reclaimed within {@link MALFORMED_GRACE_MS}.
|
||||
*/
|
||||
const unknownHolder = (): LockRecord => ({
|
||||
v: LOCK_RECORD_VERSION,
|
||||
pid: -1,
|
||||
hostname: HOSTNAME,
|
||||
startTime: null,
|
||||
token: '',
|
||||
invocationId: '<unreadable>',
|
||||
acquiredAt: '',
|
||||
});
|
||||
|
||||
/**
|
||||
* Filesystem-create error codes we tolerate by proceeding lock-free: a
|
||||
* read-only mount (EROFS) or a denied create (EACCES/EPERM). Such a filesystem
|
||||
* rejects every index WRITE in the same directory too, so no concurrent writer
|
||||
* can exist and the lock is moot — an already-indexed repo on a `:ro` mount
|
||||
* must still reach its `alreadyUpToDate` fast path (#2658). A genuinely-needed
|
||||
* write fails later exactly as it would have without the lock.
|
||||
*/
|
||||
export const LOCK_UNWRITABLE_CODES: ReadonlySet<string> = new Set(['EROFS', 'EACCES', 'EPERM']);
|
||||
export const isLockUnwritableCode = (code: string | undefined): boolean =>
|
||||
code !== undefined && LOCK_UNWRITABLE_CODES.has(code);
|
||||
|
||||
/** A lock handle that owns nothing — returned when the filesystem refuses to
|
||||
* create the lock file (see {@link LOCK_UNWRITABLE_CODES}). Release is a no-op. */
|
||||
const noopHandle = (record: LockRecord): IndexLockHandle => ({ record, release: () => {} });
|
||||
|
||||
/**
|
||||
* Delete orphaned build/staging artifacts left in the lock directory by a
|
||||
* crashed prior writer. Safe precisely because we hold the exclusive lock: no
|
||||
* other writer can be creating these here right now, so anything present is a
|
||||
* crash orphan. Matches this slot's staging files ONLY — never `lbug` itself,
|
||||
* never `lbug.wal`/`lbug.shadow` (the LIVE index's own sidecars), and never a
|
||||
* `branches/<slug>/` sub-slot (which owns its own lock + sweep). Non-recursive.
|
||||
*/
|
||||
export const sweepStagingArtifacts = (lockDir: string, log?: (msg: string) => void): void => {
|
||||
// Matches `lbug.new`, `lbug.new.wal`, `lbug.staging.<id>`, `lbug.staging.<id>.wal`, …
|
||||
// Does NOT match `lbug`, `lbug.wal`, `lbug.shadow`.
|
||||
const stagingRe = /^lbug\.(staging\..+|new(\..+)?)$/;
|
||||
let removed = 0;
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = readdirSync(lockDir);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const name of entries) {
|
||||
if (!stagingRe.test(name)) continue;
|
||||
try {
|
||||
unlinkSync(path.join(lockDir, name));
|
||||
removed++;
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
if (removed > 0) {
|
||||
log?.(`Cleared ${removed} orphaned index-staging file(s) from a prior interrupted analyze.`);
|
||||
}
|
||||
};
|
||||
|
||||
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/** Poll delay with jitter (avoids two waiters lock-stepping), clamped so it
|
||||
* never overshoots the remaining timeout budget. Callers guarantee
|
||||
* `waited < timeoutMs`, so the result is ≥ 1. */
|
||||
const jitteredDelay = (pollMs: number, timeoutMs: number, waited: number): number => {
|
||||
const jitter = Math.floor(Math.random() * pollMs);
|
||||
const remaining = timeoutMs - waited;
|
||||
return Math.max(1, Math.min(pollMs + jitter, remaining));
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the wait ceiling. Explicit `opt` wins; else
|
||||
* GITNEXUS_INDEX_LOCK_TIMEOUT_MS; else {@link DEFAULT_TIMEOUT_MS}. A value ≤ 0
|
||||
* (from either source) means unbounded.
|
||||
*/
|
||||
const resolveTimeoutMs = (opt?: number): number => {
|
||||
const raw =
|
||||
typeof opt === 'number'
|
||||
? opt
|
||||
: (() => {
|
||||
const env = process.env.GITNEXUS_INDEX_LOCK_TIMEOUT_MS;
|
||||
if (env === undefined || env === '') return DEFAULT_TIMEOUT_MS;
|
||||
const n = Number(env);
|
||||
return Number.isFinite(n) ? n : DEFAULT_TIMEOUT_MS;
|
||||
})();
|
||||
return raw <= 0 ? Number.POSITIVE_INFINITY : raw;
|
||||
};
|
||||
|
||||
/**
|
||||
* Acquire the exclusive write lock for `lockDir` (the resolved index slot
|
||||
* directory, e.g. `<repo>/.gitnexus` or `<repo>/.gitnexus/branches/<slug>`).
|
||||
*
|
||||
* Blocks until the lock is held (waiting only on live holders, stealing dead
|
||||
* ones immediately), then sweeps orphaned staging files under the lock and
|
||||
* returns a handle. Rejects with `IndexLockTimeoutError` if `timeoutMs` is
|
||||
* exceeded while a live holder still holds the lock.
|
||||
*/
|
||||
/**
|
||||
* File-based (O_EXCL pidfile) backend. The portable fallback used on platforms
|
||||
* without the socket backend (macOS/BSD) or when the OS socket lock is
|
||||
* unavailable. Carries the pid-liveness staleness, atomic rename-steal reclaim,
|
||||
* bounded malformed-file handling, and read-only tolerance. Its stale-takeover
|
||||
* has an irreducible (narrow) race — see the module header — which is why the
|
||||
* socket backend is preferred where available.
|
||||
*/
|
||||
const acquireViaFile = async (
|
||||
lockDir: string,
|
||||
me: LockRecord,
|
||||
opts: AcquireOptions,
|
||||
): Promise<IndexLockHandle> => {
|
||||
try {
|
||||
mkdirSync(lockDir, { recursive: true });
|
||||
} catch (err) {
|
||||
// Read-only / denied filesystem → proceed lock-free (see LOCK_UNWRITABLE_CODES).
|
||||
if (isLockUnwritableCode((err as NodeJS.ErrnoException).code)) return noopHandle(me);
|
||||
throw err;
|
||||
}
|
||||
const lockPath = path.join(lockDir, LOCK_FILENAME);
|
||||
const pollMs = opts.pollMs ?? DEFAULT_POLL_MS;
|
||||
const timeoutMs = resolveTimeoutMs(opts.timeoutMs);
|
||||
const startedAt = Date.now();
|
||||
let announcedWait = false;
|
||||
let lastDiagnosticAt = 0;
|
||||
// When the lock file exists but has no readable record, the timestamp we
|
||||
// first observed it unreadable — used to reclaim a crash-orphan after a grace.
|
||||
let malformedSince: number | null = null;
|
||||
|
||||
for (;;) {
|
||||
try {
|
||||
// O_WRONLY | O_CREAT | O_EXCL — the atomic arbiter of ownership.
|
||||
const fd = openSync(lockPath, 'wx');
|
||||
try {
|
||||
writeSync(fd, JSON.stringify(me));
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
// Read-back verify (#2658 review L5): if this process stalled (a >graceMs
|
||||
// GC pause) between the O_EXCL create of the *empty* file and the write
|
||||
// above, a waiter could have reclaimed the empty file (renamed it aside)
|
||||
// and O_EXCL-created its own lock at `lockPath`. Our write then landed on
|
||||
// the renamed-aside inode, not `lockPath`. Confirm `lockPath` still carries
|
||||
// our token before claiming ownership; if it was stolen, contend normally.
|
||||
const confirmed = readRecord(lockPath);
|
||||
if (!confirmed || confirmed.token !== me.token) continue;
|
||||
return {
|
||||
record: me,
|
||||
release: () => {
|
||||
const current = readRecord(lockPath);
|
||||
if (current && current.token !== me.token) return; // no longer ours
|
||||
try {
|
||||
unlinkSync(lockPath);
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === 'EEXIST') {
|
||||
// fall through to holder inspection / wait / reclaim below
|
||||
} else if (isLockUnwritableCode(code)) {
|
||||
return noopHandle(me); // read-only / denied → proceed lock-free
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const holder = readRecord(lockPath);
|
||||
const waited = Date.now() - startedAt;
|
||||
|
||||
if (holder) {
|
||||
malformedSince = null;
|
||||
if (isStale(holder)) {
|
||||
opts.log?.(
|
||||
`Reclaiming stale index lock from dead analyze (pid ${holder.pid}, ` +
|
||||
`invocation ${holder.invocationId}).`,
|
||||
);
|
||||
stealLock(lockPath, me, holder); // reclaim ONLY this dead record; live locks are never stolen
|
||||
continue;
|
||||
}
|
||||
// Live holder → wait.
|
||||
if (!announcedWait) {
|
||||
announcedWait = true;
|
||||
opts.onWaitStart?.(holder);
|
||||
opts.log?.(
|
||||
`Another gitnexus analyze (pid ${holder.pid} on ${holder.hostname}) is ` +
|
||||
`refreshing this index — waiting for it to finish.`,
|
||||
);
|
||||
}
|
||||
if (waited >= timeoutMs) throw new IndexLockTimeoutError(holder, waited);
|
||||
if (Date.now() - lastDiagnosticAt >= DIAGNOSTIC_INTERVAL_MS) {
|
||||
lastDiagnosticAt = Date.now();
|
||||
if (waited >= DIAGNOSTIC_INTERVAL_MS) {
|
||||
opts.log?.(
|
||||
`Still waiting for analyze pid ${holder.pid} (${Math.round(waited / 1000)}s elapsed).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
await sleep(jitteredDelay(pollMs, timeoutMs, waited));
|
||||
continue;
|
||||
}
|
||||
|
||||
// holder === null: the lock file is either gone (vanished between the failed
|
||||
// create and our read) or present-but-unreadable (a crash between the
|
||||
// O_EXCL create and the record write, or a partial write). NEVER hot-loop
|
||||
// here — both branches are bounded by sleep + timeout.
|
||||
if (!existsSync(lockPath)) {
|
||||
malformedSince = null; // genuinely vanished → the next create likely wins
|
||||
if (waited >= timeoutMs) throw new IndexLockTimeoutError(unknownHolder(), waited, false);
|
||||
await sleep(jitteredDelay(pollMs, timeoutMs, waited));
|
||||
continue;
|
||||
}
|
||||
// Malformed orphan present. Reclaim only after a grace, so a live owner's
|
||||
// microsecond create→write window is never mistaken for a crash.
|
||||
if (malformedSince === null) malformedSince = Date.now();
|
||||
if (Date.now() - malformedSince >= malformedGraceMs(pollMs)) {
|
||||
opts.log?.('Reclaiming a malformed/partial index lock file (no readable owner record).');
|
||||
stealLock(lockPath, me, null); // reclaim ONLY while still unreadable; a live lock written since is left
|
||||
malformedSince = null;
|
||||
continue;
|
||||
}
|
||||
if (waited >= timeoutMs) throw new IndexLockTimeoutError(unknownHolder(), waited, false);
|
||||
await sleep(jitteredDelay(pollMs, timeoutMs, waited));
|
||||
}
|
||||
};
|
||||
|
||||
/** Signals that the OS socket backend can't be used here (e.g. abstract
|
||||
* namespace disabled, sandbox, or an unexpected bind error) so the caller
|
||||
* should fall back to the file backend. NOT thrown for EADDRINUSE (that is a
|
||||
* live holder → wait) or timeouts (those propagate as IndexLockTimeoutError). */
|
||||
class SocketLockUnavailable extends Error {
|
||||
constructor(readonly cause: NodeJS.ErrnoException) {
|
||||
super(`OS socket lock unavailable: ${cause.code ?? cause.message}`);
|
||||
this.name = 'SocketLockUnavailable';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonicalize a path to its real filesystem identity so lexical aliases of the
|
||||
* same directory (a symlink, a bind-mount path, a Windows junction, a `\\?\`
|
||||
* prefix) map to ONE name (#2658 review H1). `lockDir` (the index slot) often
|
||||
* does not exist yet, so `realpathSync` the deepest existing ancestor and
|
||||
* re-append the not-yet-created remainder. A path with no symlink components
|
||||
* realpaths to itself, so the common (non-aliased) case is unchanged — a holder
|
||||
* that used the old resolved name is never orphaned.
|
||||
*/
|
||||
const canonicalizeDir = (p: string): string => {
|
||||
const resolved = path.resolve(p);
|
||||
const tail: string[] = [];
|
||||
let dir = resolved;
|
||||
for (;;) {
|
||||
try {
|
||||
const real = realpathSync(dir);
|
||||
return tail.length ? path.join(real, ...tail.reverse()) : real;
|
||||
} catch {
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return resolved; // reached the root with nothing to resolve
|
||||
tail.push(path.basename(dir));
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Stable OS-IPC endpoint name for an index directory. The name is derived from
|
||||
* the REAL path (case-folded on Windows), so two processes targeting the same
|
||||
* physical slot — even via different lexical aliases — collide, and separate
|
||||
* worktrees/branches never do. The endpoint lives OUTSIDE the index directory
|
||||
* (abstract namespace / pipe namespace), so the lock needs no filesystem write
|
||||
* and is unaffected by a read-only index mount.
|
||||
*/
|
||||
const socketLockName = (lockDir: string): string => {
|
||||
const resolved = canonicalizeDir(lockDir);
|
||||
const key = createHash('sha256')
|
||||
.update(process.platform === 'win32' ? resolved.toLowerCase() : resolved)
|
||||
.digest('hex')
|
||||
.slice(0, 32);
|
||||
return process.platform === 'win32'
|
||||
? `\\\\.\\pipe\\gitnexus-idx-${key}`
|
||||
: `\0gitnexus-idx-${key}`; // Linux abstract socket (no filesystem entry)
|
||||
};
|
||||
|
||||
/** Attempt to listen; resolve to null on success or the error on failure. */
|
||||
const tryListen = (server: net.Server, name: string): Promise<NodeJS.ErrnoException | null> =>
|
||||
new Promise((resolve) => {
|
||||
const onError = (err: NodeJS.ErrnoException): void => {
|
||||
server.removeListener('listening', onListening);
|
||||
resolve(err);
|
||||
};
|
||||
const onListening = (): void => {
|
||||
server.removeListener('error', onError);
|
||||
resolve(null);
|
||||
};
|
||||
server.once('error', onError);
|
||||
server.once('listening', onListening);
|
||||
server.listen(name);
|
||||
});
|
||||
|
||||
/**
|
||||
* OS-owned socket/pipe backend (Windows named pipe, Linux abstract socket).
|
||||
* Holding the lock = holding a listening endpoint the kernel binds to this
|
||||
* process; `EADDRINUSE` therefore means a *live* holder, and the kernel drops
|
||||
* the binding the instant the holder exits (clean exit, crash, OOM, SIGKILL) —
|
||||
* so there is no stale detection, no reclaim, and no takeover race. See the
|
||||
* module header for why this is preferred over the file backend.
|
||||
*/
|
||||
const acquireViaSocket = async (
|
||||
lockDir: string,
|
||||
me: LockRecord,
|
||||
opts: AcquireOptions,
|
||||
): Promise<IndexLockHandle> => {
|
||||
const name = socketLockName(lockDir);
|
||||
const pollMs = opts.pollMs ?? DEFAULT_POLL_MS;
|
||||
const timeoutMs = resolveTimeoutMs(opts.timeoutMs);
|
||||
const startedAt = Date.now();
|
||||
let announcedWait = false;
|
||||
let lastDiagnosticAt = 0;
|
||||
|
||||
for (;;) {
|
||||
const server = net.createServer();
|
||||
// Never keep the process alive on the lock's account, and never hold an
|
||||
// incoming connection (nothing should connect; drop any stray peer).
|
||||
server.unref();
|
||||
server.on('connection', (sock) => sock.destroy());
|
||||
const listenErr = await tryListen(server, name);
|
||||
|
||||
if (!listenErr) {
|
||||
return {
|
||||
record: me,
|
||||
release: () => {
|
||||
try {
|
||||
server.close();
|
||||
} catch {
|
||||
/* already closed / releasing on exit */
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// This server never bound (listen failed); release its handle before the
|
||||
// next poll or the fallback, so a long contended wait doesn't churn one
|
||||
// unclosed net.Server per iteration (#2658 review L3).
|
||||
try {
|
||||
server.close();
|
||||
} catch {
|
||||
/* never listened */
|
||||
}
|
||||
|
||||
// Only EADDRINUSE means "held by a live holder → wait". Anything else means
|
||||
// this environment can't use the socket backend → fall back to the file one.
|
||||
if (listenErr.code !== 'EADDRINUSE') throw new SocketLockUnavailable(listenErr);
|
||||
|
||||
if (!announcedWait) {
|
||||
announcedWait = true;
|
||||
opts.onWaitStart?.(me);
|
||||
opts.log?.('Another gitnexus analyze is refreshing this index — waiting for it to finish.');
|
||||
}
|
||||
const waited = Date.now() - startedAt;
|
||||
// Socket backend exposes no owner metadata → holder identity is unknown (M3).
|
||||
if (waited >= timeoutMs) throw new IndexLockTimeoutError(unknownHolder(), waited, false);
|
||||
if (Date.now() - lastDiagnosticAt >= DIAGNOSTIC_INTERVAL_MS) {
|
||||
lastDiagnosticAt = Date.now();
|
||||
if (waited >= DIAGNOSTIC_INTERVAL_MS) {
|
||||
opts.log?.(`Still waiting for another analyze (${Math.round(waited / 1000)}s elapsed).`);
|
||||
}
|
||||
}
|
||||
await sleep(jitteredDelay(pollMs, timeoutMs, waited));
|
||||
}
|
||||
};
|
||||
|
||||
/** Platforms whose OS IPC namespace gives a clean, auto-releasing lock via
|
||||
* `net`: Windows named pipes and Linux abstract sockets. Elsewhere (macOS/BSD)
|
||||
* the file backend is used (no abstract namespace; filesystem sockets don't
|
||||
* release cleanly on death). Override for tests via GITNEXUS_INDEX_LOCK_BACKEND
|
||||
* = 'socket' | 'file'.
|
||||
*
|
||||
* Scope caveat: the socket backend's mutual-exclusion domain is NOT uniform.
|
||||
* Windows `\\.\pipe\` names are machine-wide (all sessions); Linux abstract
|
||||
* sockets are network-namespace-scoped (network_namespaces(7)). So two writers
|
||||
* that share a bind-mounted index dir but sit in separate netns (e.g. two
|
||||
* containers, Docker's default) do NOT collide on Linux — "single-host" is
|
||||
* really "single-netns" here. That cross-netns-shared-mount case is the one
|
||||
* the file backend (shared-filesystem O_EXCL) would cover; set
|
||||
* GITNEXUS_INDEX_LOCK_BACKEND=file there. The motivating case (local hook-
|
||||
* driven re-index) is single-netns, so the default socket backend covers it. */
|
||||
const selectBackend = (): 'socket' | 'file' => {
|
||||
const override = process.env.GITNEXUS_INDEX_LOCK_BACKEND;
|
||||
if (override === 'socket' || override === 'file') return override;
|
||||
return process.platform === 'win32' || process.platform === 'linux' ? 'socket' : 'file';
|
||||
};
|
||||
|
||||
/**
|
||||
* Acquire the exclusive write lock for `lockDir` (the resolved index slot
|
||||
* directory). Uses the OS socket/pipe backend where available (Windows/Linux),
|
||||
* falling back to the file backend otherwise or if the socket backend is
|
||||
* unusable in this environment. After acquiring, sweeps orphaned staging files
|
||||
* under the lock (best-effort; a no-op on a read-only mount). Rejects with
|
||||
* `IndexLockTimeoutError` if `timeoutMs` elapses while another live holder holds
|
||||
* the lock.
|
||||
*/
|
||||
export const acquireIndexLock = async (
|
||||
lockDir: string,
|
||||
opts: AcquireOptions = {},
|
||||
): Promise<IndexLockHandle> => {
|
||||
const me = buildRecord();
|
||||
let handle: IndexLockHandle;
|
||||
if (selectBackend() === 'socket') {
|
||||
try {
|
||||
handle = await acquireViaSocket(lockDir, me, opts);
|
||||
} catch (err) {
|
||||
if (!(err instanceof SocketLockUnavailable)) throw err; // timeout etc. propagate
|
||||
opts.log?.('Index lock: OS socket lock unavailable here — using the file lock.');
|
||||
handle = await acquireViaFile(lockDir, me, opts);
|
||||
}
|
||||
} else {
|
||||
handle = await acquireViaFile(lockDir, me, opts);
|
||||
}
|
||||
// Reclaim crashed-build staging orphans while we hold the lock. Best-effort:
|
||||
// a read-only mount (no orphans reachable) just no-ops.
|
||||
try {
|
||||
sweepStagingArtifacts(lockDir, opts.log);
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
return handle;
|
||||
};
|
||||
|
|
@ -55,13 +55,22 @@ 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.
|
||||
// v22: `const X = <arrow | function-expression>` emits one `Function` node
|
||||
// instead of a `Function` plus an edgeless `Const` twin (#2687). Cached worker
|
||||
// results are replayed verbatim — including across `--force` — so without this
|
||||
// bump a warm cache keeps serving the old two-node set.
|
||||
// v21: Java/Kotlin Spring DI facts persist constructor, field/property, and
|
||||
// method injection sites plus bean-name and @Primary provider metadata.
|
||||
// v20: Java/Kotlin capture side-channels persist package and class-annotation
|
||||
// facts for shared Spring Bean resolution.
|
||||
// v21: Java local class/enum/record/interface captures use javac-compatible,
|
||||
// source-type-relative JLS 13.1 identities and declaration-to-block scopes
|
||||
// (#2562).
|
||||
// v19: Java enum constant bodies emit E$N Class nodes; anonymous naming uses
|
||||
// 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 = 20;
|
||||
const SCHEMA_BUMP = 22;
|
||||
const GITNEXUS_PKG_VERSION = (() => {
|
||||
try {
|
||||
// package.json sits at gitnexus/package.json — two levels up from
|
||||
|
|
|
|||
|
|
@ -461,19 +461,51 @@ export interface RepoMeta {
|
|||
* incremental write set only covers changed files, so a top-up against a
|
||||
* pre-v11 index would keep silently missing these CALLS edges for every
|
||||
* unchanged Rust trait file; force a full re-analyze instead.
|
||||
* v12: main-aptos merge. The Move lineage's v9 — `attributesJson` column added
|
||||
* to the Move node tables (Function, Struct, Enum, EnumVariant, Module) with
|
||||
* full attribute payloads — is re-numbered past main's v9–v11: a persisted
|
||||
* stamp of 9 is ambiguous between the two lineages, and a pre-merge Move
|
||||
* index also predates main's v9–v11 rebuild reasons. Any pre-v12 stamp fails
|
||||
* the strict-equality reuse gate; force a full re-analyze (same contract as v3).
|
||||
* v15: generic Type nodes and the Move EnumVariant→Property / field
|
||||
* USES_TYPE endpoint pairs became persistable. This Move-lineage change is
|
||||
* numbered past upstream main's v13 (Java local-class identities) and v14 so
|
||||
* indexes from either release channel cannot pass the strict-equality reuse
|
||||
* gate under the other schema. Older indexes require a full rebuild.
|
||||
* — Between v11 and v16 the version history forks: the aptos release channel
|
||||
* (main-aptos) and upstream main each allocated stamps 12–15 independently,
|
||||
* so a persisted stamp in that range is ambiguous between the two lineages.
|
||||
* Upstream main's 12–15:
|
||||
* v12: Rust range-binding stopped restoring ambiguous duplicate type names
|
||||
* (#2514): a function/struct name defined three or more times used to
|
||||
* re-resolve to the last-scanned file (a presence toggle), so odd duplicate
|
||||
* counts emitted a wrong cross-file CALLS edge. Same v7/v11 contract: the
|
||||
* incremental write set only covers changed files, so a top-up against a
|
||||
* pre-v12 index would keep these spurious CALLS edges on every unchanged Rust
|
||||
* file. v12 also changes edges in the other direction: range-binding now
|
||||
* RESOLVES import-disambiguated duplicate names (`for item in make()` /
|
||||
* `let Struct { f } = ..` where a `use` or `use x::*` import pins one of several
|
||||
* same-named definitions) to the imported definition's type. Both the removed
|
||||
* spurious edges and these new resolved edges are cross-file, so a pre-v12
|
||||
* top-up would leave unchanged Rust files stale either way; force a full
|
||||
* re-analyze instead.
|
||||
* v13: Java local classes, enums, records, and interfaces use
|
||||
* source-type-relative JLS 13.1 identities (`Outer$1Local`). Number allocation
|
||||
* matches javac: one sequence per (enclosing type, local simple name), with a
|
||||
* separate sequence for anonymous types. Existing type/member ids, lexical
|
||||
* bindings, and ownership edges must not be mixed with newly named unchanged
|
||||
* Java files; force a full re-analyze.
|
||||
* v14: C# and Kotlin free-call fallback now rejects same-file methods whose
|
||||
* instance owner is outside the caller's enclosing class/MRO (#2563). The
|
||||
* incremental write set would otherwise retain those stale CALLS edges on
|
||||
* every unchanged C# and Kotlin file; force a full re-analyze instead.
|
||||
* v15: `const X = <arrow | function-expression>` no longer emits an edgeless
|
||||
* `Const:<file>:X` twin beside its `Function` node (#2687). The incremental
|
||||
* write set only covers changed files, so every unchanged TS/JS file would
|
||||
* keep its twin and `impact`/`context` would stay ambiguous on those names;
|
||||
* force a full re-analyze instead.
|
||||
* The aptos lineage's 12–15:
|
||||
* v12: first main-aptos merge — the Move lineage's v9 (`attributesJson` column
|
||||
* on the Move node tables) re-numbered past main's v9–v11.
|
||||
* v13/v15: generic Type nodes and the Move EnumVariant→Property / field
|
||||
* USES_TYPE endpoint pairs became persistable (v13 in PR #2682, renumbered to
|
||||
* 15 to clear main's then-current v14).
|
||||
* v16: second main-aptos merge unifies the fork. Any stamp 12–15 is ambiguous
|
||||
* between the lineages: an aptos-15 index predates main's v12–v15 rebuild
|
||||
* reasons and a main-15 index has no Move/Type tables. Any pre-v16 stamp
|
||||
* fails the strict-equality reuse gate; force a full re-analyze (same
|
||||
* contract as the v12 merge).
|
||||
*/
|
||||
export const INCREMENTAL_SCHEMA_VERSION = 15;
|
||||
export const INCREMENTAL_SCHEMA_VERSION = 16;
|
||||
|
||||
export interface IndexedRepo {
|
||||
repoPath: string;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { ProcessDetectionResult } from '../core/ingestion/process-processor.js';
|
|||
import type { StandaloneIngestOutput } from '../core/ingestion/pipeline-phases/standalone-ingest.js';
|
||||
import type { ResolutionOutcome } from '../core/ingestion/scope-resolution/resolution-outcome.js';
|
||||
import type { PdgEmitManifest } from '../core/lbug/pdg-emit-sink.js';
|
||||
import type { GraphEmitManifest } from '../core/lbug/graph-emit-sink.js';
|
||||
|
||||
// CLI-specific: in-memory result with graph + detection results
|
||||
export interface PipelineResult<
|
||||
|
|
@ -45,4 +46,12 @@ export interface PipelineResult<
|
|||
* register a richer phase may narrow this output to their phase contract.
|
||||
*/
|
||||
standaloneIngest: TStandaloneIngest;
|
||||
/**
|
||||
* Streamed structural-emit COPY manifest (#2680). Present only when
|
||||
* `streamGraphEmit` was active (full rebuild + enabled): the per-pair CSVs of
|
||||
* relationships that never entered the in-memory graph, for `loadGraphToLbug`
|
||||
* to COPY ALONGSIDE the whole-graph CSVs (their pair keys overlap, so they are
|
||||
* additional COPY jobs, not map entries).
|
||||
*/
|
||||
graphEmitManifest?: GraphEmitManifest;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -659,6 +659,14 @@
|
|||
"captureGroups": 12,
|
||||
"digest": "b6f9dd906e1309338f21633d71e663cdfd95a8707d38b9a8bf74813415ee5d13"
|
||||
},
|
||||
"csharp-using-static/App/NamespaceOwnerCollision.cs": {
|
||||
"captureGroups": 16,
|
||||
"digest": "d78082d240d14417ad3f502ef1e96e8e3575dd4e9cec00cfc15c7230c596ca53"
|
||||
},
|
||||
"csharp-using-static/App/SameFileCases.cs": {
|
||||
"captureGroups": 67,
|
||||
"digest": "50f41faf386131ecbf6cf9c22b3594cbafee8291f385252aad7c10202881d27e"
|
||||
},
|
||||
"csharp-using-static/Helpers/MathUtils.cs": {
|
||||
"captureGroups": 8,
|
||||
"digest": "32c174cbaade4e2d6e0aa7e95a2b5addd138441deb43ced286c9cf5cd30750aa"
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue