Merge pull request #2697 from abhigyanpatwari/sync/main-to-main-aptos

Merge main into main-aptos (schema v16)
This commit is contained in:
Abhigyan Patwari 2026-07-26 06:54:05 +05:30 committed by GitHub
commit 304aa00a8a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
211 changed files with 14033 additions and 1116 deletions

View file

@ -81,6 +81,18 @@ list_repos { offset: 400 } → repos 401437, 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.

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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'

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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: >-

View file

@ -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

View file

@ -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.
---

View file

@ -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

View file

@ -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. |

View 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."}

View file

@ -81,6 +81,18 @@ list_repos { offset: 400 } → repos 401437, 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.

View file

@ -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

View file

@ -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 ───────────────────────────────────────────────────────────

View file

@ -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": {

View file

@ -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"
},

View file

@ -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

View file

@ -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."
}
}

View file

@ -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 ----

View file

@ -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": {

View file

@ -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

View file

@ -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)');

View file

@ -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.

View file

@ -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

View file

@ -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'}`);

View file

@ -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',

View file

@ -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':

View file

@ -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':

View file

@ -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.',
)

View file

@ -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(

View file

@ -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 };
};

View file

@ -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',
};
}

View file

@ -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

View file

@ -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;
}

View file

@ -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
);
},
};

View file

@ -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>,

View file

@ -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],
]);

View file

@ -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;
},
};

View file

@ -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.

View 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;
}
}
}
}
}
};
}

View file

@ -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

View file

@ -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',
});
}
}
}

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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),

View file

@ -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

View file

@ -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);
},
};

View 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',
});

View file

@ -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

View file

@ -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,

View file

@ -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;
}

View file

@ -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);
},
};
/**

View 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,
});

View file

@ -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

View file

@ -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);
}

View file

@ -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) {

View file

@ -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,
};
},
};

View file

@ -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

View file

@ -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,
};
},

View file

@ -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 ───────────────────────────────────────────────────

View file

@ -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,

View file

@ -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 };
};

View file

@ -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')) ?? ''

View file

@ -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

View file

@ -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,

View file

@ -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).

View file

@ -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`.

View file

@ -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

View file

@ -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; */

View file

@ -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,
};
}
}

View 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());
}

View file

@ -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;
}
}

View file

@ -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).

View file

@ -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

View 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;
}
}

View file

@ -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);
});

View file

@ -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 {

View file

@ -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 {

View file

@ -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);
}

View file

@ -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> => {

View 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 */
}
}
}
}

View file

@ -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

View file

@ -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) {

View file

@ -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) };
}
}

View file

@ -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,

View file

@ -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'],
});

View file

@ -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

View file

@ -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. */

View file

@ -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', {

View 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
* createwriteclose 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;
};

View file

@ -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

View file

@ -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 v9v11: a persisted
* stamp of 9 is ambiguous between the two lineages, and a pre-merge Move
* index also predates main's v9v11 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 EnumVariantProperty / 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 1215 independently,
* so a persisted stamp in that range is ambiguous between the two lineages.
* Upstream main's 1215:
* 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 1215:
* v12: first main-aptos merge the Move lineage's v9 (`attributesJson` column
* on the Move node tables) re-numbered past main's v9v11.
* v13/v15: generic Type nodes and the Move EnumVariantProperty / 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 1215 is ambiguous
* between the lineages: an aptos-15 index predates main's v12v15 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;

View file

@ -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;
}

View file

@ -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