Merge origin/main (212e007a) into feat/zig-language-support

GitHub reported the branch as conflicting with main (61 commits behind).
One textual conflict, `bench/scope-capture/baselines.json` (kotlin): both
sides moved the fingerprint, and a fingerprint is never resolved by picking
a side — recomputed under the merged tree. kotlin composes main's #2885
(5753/18403/2563) with this branch's member-call emission change; csharp /
cpp / typescript measure byte-for-byte at this branch's values (main did
not touch them since the merge-base); the other 11 languages report ok.

Also bumps SCHEMA_BUMP 91 -> 92 (checked against origin/main at merge
time): this branch changes parse-time capture facts for Kotlin / C++ / C# /
TypeScript member calls and captures Zig for the first time, and a warm v91
cache replays the old facts verbatim, `--force` included — the reviewer's
re-test on an earlier head of this PR measured a byte-identical graph until
the parse caches were deleted by hand.
This commit is contained in:
Navid EMAD 2026-09-02 21:46:53 +02:00
commit f121e539ed
No known key found for this signature in database
511 changed files with 74071 additions and 2804 deletions

View file

@ -6,7 +6,7 @@
"plugins": [
{
"name": "gitnexus",
"version": "1.6.9",
"version": "1.6.10",
"source": {
"source": "local",
"path": "./gitnexus-claude-plugin"

View file

@ -11,7 +11,7 @@
"plugins": [
{
"name": "gitnexus",
"version": "1.6.9",
"version": "1.6.10",
"source": "./gitnexus-claude-plugin",
"description": "Code intelligence powered by a knowledge graph. Provides execution flow tracing, blast radius analysis, and augmented search across your codebase."
}

View file

@ -21,13 +21,20 @@ Run from the project root. This parses all source files, builds the knowledge gr
| Flag | Effect |
| -------------- | ---------------------------------------------------------------- |
| `--watch` | Keep a Git repository index current with serialized refreshes |
| `--debounce <ms>` | Watch quiet period before refresh (default: 300 ms) |
| `--force` | Force full re-index even if up to date |
| `--embeddings` | Enable embedding generation for semantic search (off by default) |
| `--drop-embeddings` | Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` preserves them. |
| `--pdg` | Build the program-dependence layers used by `explain` and `pdg_query` (taint, CDG, and REACHING_DEF). |
| `--spring-actuator <path>` | Import opt-in Spring Boot Actuator mappings, beans, conditions, configprops, and env snapshots. Forces a full rebuild; unsupported with `--watch`. |
**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook detects staleness after `git commit` and `git merge` and notifies the agent to run `analyze` — the hook does not run analyze itself, to avoid blocking the agent for up to 120s and risking KuzuDB corruption on timeout.
For Spring runtime enrichment, pass a JSON bundle, one endpoint JSON file, or a directory containing endpoint files. Route evidence is authoritative only when `runtimeConfirmed === true`; `runtimeSource` records provenance and may also accompany `handler-conflict`. Env/configprops values are never persisted.
Use `node .gitnexus/run.cjs analyze --watch` for a long-lived local Git repository. It performs an initial analysis, queues scanner-admitted file changes, and retries intact failed batches with bounded backoff. Watch refreshes update only the graph: they skip AGENTS.md / CLAUDE.md injection and standard skill installation, so run a one-shot `analyze` when those generated files need updating. Watch rejects one-shot or context-output flags including `--force`, embedding flags, `--skills`, `--default-branch`, `--skip-agents-md`, `--skip-skills`, `--no-stats`, `--self-commit`, `--index-only`, and `--skip-git`. It never pulls remotes. Scheduled remote clone/pull is a different command: `gitnexus auto-sync`. Bare `gitnexus watch` is reserved and does not start either job. Running MCP and `serve` processes periodically check for a published replacement and reopen it without a restart. MCP checks are throttled to once every five seconds, so a tool call before the next check can briefly use the previous index.
### status — Check index freshness
```bash
@ -55,15 +62,19 @@ Deletes the `.gitnexus/` directory and unregisters the repo from the global regi
node .gitnexus/run.cjs wiki
```
Generates repository documentation from the knowledge graph using an LLM. Requires an API key (saved to `~/.gitnexus/config.json` on first use).
Generates repository documentation from the knowledge graph using an LLM. HTTP providers require an API key (saved to `~/.gitnexus/config.json` on first use). Local CLI providers (`--provider cursor|claude|codex|opencode|grok`) use your existing CLI login.
| Flag | Effect |
| ------------------- | ----------------------------------------- |
| `--force` | Force full regeneration |
| `--force` | Force full regeneration, also required to re-generate an existing wiki in a different language |
| `--provider <name>` | LLM provider: minimax, openai, openrouter, azure, custom, cursor, claude, codex, opencode, or grok (default: minimax). Local CLIs (`cursor`, `claude`, `codex`, `opencode`, `grok`) use your existing CLI login and skip `--api-key`. |
| `--model <model>` | LLM model (default: MiniMax-M3) |
| `--base-url <url>` | LLM API base URL |
| `--api-key <key>` | LLM API key |
| `--concurrency <n>` | Parallel LLM calls (default: 3) |
| `--timeout <seconds>` | LLM request timeout in seconds (default: disabled) |
| `--retries <n>` | Max LLM retry attempts per request (default: 3) |
| `--lang <lang>` | Output language for generated documentation (e.g. english, chinese, spanish, japanese) |
| `--gist` | Publish wiki as a public GitHub Gist |
### list — Show all indexed repos
@ -82,5 +93,5 @@ Lists all repositories registered in `~/.gitnexus/registry.json`. The MCP `list_
## Troubleshooting
- **"Not inside a git repository"**: Run from a directory inside a git repo
- **Index is stale after re-analyzing**: Restart Claude Code to reload the MCP server
- **Index is stale after re-analyzing**: Wait for the next MCP tool call to reopen the published index; this normally takes no more than five seconds
- **Embeddings slow**: Omit `--embeddings` (it's off by default) or set `OPENAI_API_KEY` for faster API-based embedding

View file

@ -93,6 +93,15 @@ dispatch, cross-language calls), so few-callers ⇒ LOW does **not** apply. The
result carries a `riskNote` saying so. Confirm with a text search before
treating the symbol as safe to change or delete.
`risk` is the edit gate: warn on HIGH/CRITICAL and stop on UNKNOWN until the
uncertainty is resolved. Within single-repo mode, compare File and symbol
targets with local `riskSharedAxes` (direct/total only). Within group mode,
compare only group results: their `riskSharedAxes` overlays resolved
cross-repo crossings on that local value. Never use either field to waive the
edit gate. Check `riskScale.unusedAxes` before comparing kinds: MCP File walks
omit process/module axes, while web Graph-RAG expands File targets to in-file
symbols before enrichment.
## Tools
**impact** — the primary tool for symbol blast radius. If MCP is unavailable, use `node .gitnexus/run.cjs impact <symbol> --direction upstream --repo .` instead:

12
.gitattributes vendored
View file

@ -15,3 +15,15 @@
*.so binary
*.dll binary
*.dylib binary
# TypeScript sources are always text for diff purposes. Git's binary
# heuristic fires when EITHER blob in a pair carries a NUL, so a source
# file that carried one on a base commit still renders as "Binary files
# differ" — with no hunks and no inline comments — long after the byte
# itself is gone from the working tree. A head-side guard cannot see
# that, by construction. This does not mark the files binary or change
# how they are stored; it only stops the heuristic from hiding a diff.
*.ts diff
*.tsx diff
*.mts diff
*.cts diff

View file

@ -493,6 +493,20 @@ jobs:
node --import tsx bench/python-scope/import-target-fingerprint.mjs --check
working-directory: gitnexus
- name: Java wildcard-static route constant guards (#3110)
if: ${{ !cancelled() }}
# Build-free: named-import control vs wildcard materialization;
# fingerprints bindings and guards scaling + absolute wall time.
run: node --import tsx bench/java-wildcard-route-constants/measure.mjs --check
working-directory: gitnexus
- name: Kotlin package-star route constant guards (#3110)
if: ${{ !cancelled() }}
# Build-free: explicit-import control vs package-star folding;
# fingerprints route facts and guards scaling + widening overhead.
run: node --import tsx bench/kotlin-star-route-constants/measure.mjs --check
working-directory: gitnexus
- name: Cross-language scope-capture fingerprint + scaling guards
# Runs even after an earlier guard fails (#2895). Every step here was
# fail-fast, so the FIRST failing --check aborted the job and every guard
@ -521,6 +535,31 @@ jobs:
run: node --import tsx bench/callable-value-flow/measure.mjs --check
working-directory: gitnexus
- name: Java Lombok accessor synthesis guards (#2885)
if: ${{ !cancelled() }}
# Build-free: no-Lombok vs Lombok-heavy corpora; fingerprint over
# synthetic Method ids; scaling + widening overhead budgets.
run: node --import tsx bench/java-lombok-synthesis/measure.mjs --check
working-directory: gitnexus
- name: Kotlin JVM accessor synthesis guards (#2885)
if: ${{ !cancelled() }}
# Build-free: no-property vs data-class corpora; fingerprint over
# synthetic Method ids; scaling + widening overhead budgets.
run: node --import tsx bench/kotlin-jvm-accessors/measure.mjs --check
working-directory: gitnexus
- name: Kotlin Spring config-consumer capture guards (#2412)
if: ${{ !cancelled() }}
# Build-free: explicit-import control vs wildcard-import feature path;
# fingerprints @Value / @ConfigurationProperties facts and guards scaling
# + widening overhead. The parity check is the regression gate: each file
# declares a sibling nested type named `Value`, which must not suppress
# the imported Spring annotation (file-wide shadowing dropped 2 of every
# 3 facts on this corpus).
run: node --import tsx bench/spring-config-bindings/measure.mjs --check
working-directory: gitnexus
- name: Re-export closure scaling guards (#2864)
# Build-free: asserts buildReexportClosures stays linear in chain depth
# and within an absolute ceiling on a wide package corpus. #2864 changed
@ -723,20 +762,22 @@ jobs:
- name: Cross-language pipeline benchmarks (GITNEXUS_BENCH, serial)
if: ${{ !cancelled() }}
# cpp-adl-benchmark.test.ts is not a `*-pipeline-benchmark.test.ts` but
# belongs here for the same reason: it is skipIf-gated on GITNEXUS_BENCH,
# so it had never run in CI and the PR #1990 ADL emit-scaling guard it
# holds was dead. ~45s of test time.
# cpp-adl-benchmark.test.ts and csharp-razor-view-components-benchmark.test.ts
# are not `*-pipeline-benchmark.test.ts` files but belong here for the
# same reason: they are skipIf-gated on GITNEXUS_BENCH, so the scaling
# guards they hold never run in the main coverage job.
env:
GITNEXUS_BENCH: '1'
run: >-
npx vitest run --no-file-parallelism
test/integration/cobol-pipeline-benchmark.test.ts
test/integration/csharp-pipeline-benchmark.test.ts
test/integration/csharp-razor-view-components-benchmark.test.ts
test/integration/cpp-adl-benchmark.test.ts
test/integration/data-route-table-benchmark.test.ts
test/integration/instance-ownership-pipeline-benchmark.test.ts
test/integration/spring-bean-resource-benchmark.test.ts
test/integration/spring-dynamic-lookup-benchmark.test.ts
test/integration/rust-pipeline-benchmark.test.ts
test/integration/php-pipeline-benchmark.test.ts
test/integration/ruby-pipeline-benchmark.test.ts

View file

@ -48,7 +48,7 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
languages: ${{ matrix.language }}
queries: security-and-quality
@ -71,8 +71,14 @@ jobs:
# deliberately contain use-before-init / unused-variable shapes).
- '**/test/fixtures/**'
- '**/test/**/fixtures/**'
# GET /api/grep intentionally builds RegExp from the query string
# (literal=1 escapes). ReDoS is handled by worker terminate() —
# see SECURITY.md. Inline codeql[] comments do not clear the
# GitHub PR CodeQL gate, so this file is excluded to avoid
# re-filing js/regex-injection on every push of the same line.
- 'gitnexus/src/server/grep-params.ts'
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
category: '/language:${{ matrix.language }}'

View file

@ -141,7 +141,7 @@ jobs:
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Install Cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2

View file

@ -53,6 +53,6 @@ jobs:
retention-days: 5
- name: Upload to Security tab
uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
sarif_file: results.sarif

View file

@ -50,7 +50,7 @@ jobs:
persist-credentials: false
- name: Setup Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Build image (load locally for scan)
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
@ -76,7 +76,7 @@ jobs:
exit-code: '0'
- name: Upload to Security tab
uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
sarif_file: trivy-${{ matrix.image.name }}.sarif
category: trivy-${{ matrix.image.name }}

View file

@ -76,7 +76,7 @@ jobs:
continue-on-error: true
- name: Upload SARIF
uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
sarif_file: zizmor.sarif
category: zizmor

2
.gitignore vendored
View file

@ -31,6 +31,8 @@ npm-debug.log*
# Testing
coverage/
.tmp-test/
gitnexus/.tmp-test/
# Misc
*.local

View file

@ -1,2 +1,4 @@
# Deleted README placeholder from PR #2458; no credential was present.
c9fdab17f25ebaf332fba6e6ba55ee328f20fe66:README.md:curl-auth-header:348
# Synthetic Kotlin Actuator fixture value from PR #3107; no credential was present.
3951079300a18b14e79f5b5f5dd778ae19ced6e3:gitnexus/test/integration/spring-actuator-kotlin-runtime-pipeline.test.ts:generic-api-key:8

View file

@ -119,10 +119,9 @@ This project is indexed by GitNexus as **GitNexus** (248612 symbols, 565510 rela
- **MUST run impact analysis before editing.** Use `impact({target: "symbolName", direction: "upstream"})` (MCP) or `node .gitnexus/run.cjs impact "symbolName" --direction upstream --repo .` (CLI fallback); report callers, processes, and risk. Never substitute grep for graph analysis. For unified PDG impact, add `mode: "pdg"` with optional `line: <N>` — it returns statement-level `affectedStatements` over CDG + REACHING_DEF and inter-procedural symbols in `interproceduralByDepth`/`byDepth`; no-layer/degraded PDG results are UNKNOWN-risk notes (`--pdg` layer). CLI equivalent: `node .gitnexus/run.cjs impact "symbolName" --direction upstream --mode pdg --line <N> --repo .`.
- **MUST analyze graph changes before committing.** Use `detect_changes({scope: "all"})` (MCP) or `node .gitnexus/run.cjs detect-changes --scope all --repo .` (CLI fallback). `partial: true` or `truncated: true` is not a clean check — a zero means unseen, not unaffected; re-run it. For regression review: `detect_changes({scope: "compare", base_ref: "main"})` or `node .gitnexus/run.cjs detect-changes --scope compare --base-ref "main" --repo .`.
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
- MUST warn on HIGH/CRITICAL `risk` pre-edit; never use `riskSharedAxes` to waive a HIGH/CRITICAL `risk` warning. Compare File/symbol: MCP File omits axes; Graph-RAG expands File.
- **MUST treat `risk: UNKNOWN` as unresolved, not as low.** An empty caller set is not evidence the symbol is unused — it can also mean the callers are not resolvable by the index (plain-object property access, dynamic dispatch, cross-language calls). `impact` pairs `UNKNOWN` with a `riskNote` saying so. Confirm with a text search before treating the symbol as safe to change or delete; do not proceed on the strength of a zero.
- When exploring unfamiliar code, use `query({search_query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `context({name: "symbolName"})`.
- **MUST use `query({search_query: "concept"})` for concepts/flows, `context({name: "symbolName"})` for a named symbol, or `impact` for blast radius, on read-only callers, dependencies, imports, or execution flow.** Graph first; text search only for empty/`UNKNOWN`/literals.
- For security review, `explain({target: "fileOrSymbol"})` lists taint findings (source→sink flows; needs `analyze --pdg`).
- For control/data dependence, `pdg_query({mode: "controls", target: "fileOrSymbol"})` answers "under what condition does X run?" (CDG, incl. guard clauses) and `pdg_query({mode: "flows", target, variable})` traces "where does variable Y flow?" (REACHING_DEF). `--pdg` layer.

View file

@ -108,7 +108,7 @@ scan → structure → [springConfig, markdown, cobol] → parse → [routes, to
| `pruneLocalSymbols` | `prune-local-symbols.ts` | `scopeResolution` | Drops inert block-local `Const`/`Variable`/`Static` nodes (only a `File→DEFINES` edge) post-resolution |
| `mro` | `mro.ts` | `crossFile`, `scopeResolution`, `pruneLocalSymbols`, `structure` | METHOD_OVERRIDES + METHOD_IMPLEMENTS edges |
| `springAopInheritance` | `spring-aop.ts` | `springAop`, `mro` | Propagates declarative behavior through class/interface inheritance decisions |
| `di` | `di.ts` | `mro` | INJECTS edges from consumer Classes or factory Methods to provider Classes/declaration CodeElements (framework-neutral DI resolution; per-language matchers registered in `di-extractors/`) |
| `di` | `di.ts` | `mro` | INJECTS edges from consumer Classes, factory Methods, or AST-captured programmatic lookup callables to provider Classes/declaration CodeElements (framework-neutral DI resolution; per-language matchers registered in `di-extractors/`) |
| `communities` | `communities.ts` | `mro`, `pruneLocalSymbols`, `structure` | Community nodes + MEMBER_OF edges (Leiden algorithm) |
| `processes` | `processes.ts` | `communities`, `routes`, `tools`, `pruneLocalSymbols`, `structure` | Process nodes + STEP_IN_PROCESS edges |
@ -174,7 +174,7 @@ converging on the routes phase's `(method, url)` registry:
| Filesystem convention | path → URL, no parsing | Next.js `app/`, Expo, PHP |
| Single-file framework route | `isRouteFile` + worker extraction | Laravel `routes/*.php` |
| Cross-file framework route | `discoverRootRouteFiles` + `extractRoutes` | Django `urlpatterns` |
| AST-level route in a normal file | `extractDecoratorRoutes` | Spring, FastAPI, NestJS, **JS/TS dispatch guards and static data route tables** |
| AST-level route in a normal file | `extractDecoratorRoutes` | Spring, FastAPI, NestJS (`@Controller` + `@Get`/`@Post`/…; URLs are controller-relative — `setGlobalPrefix` and URI versioning live in the bootstrap file and are not applied), **JS/TS dispatch guards and static data route tables** |
The last row is the one whose name undersells it. A route is DECLARED by a
decorator, but it can also be **inferred** from a raw `node:http` server's own
@ -403,6 +403,7 @@ Each language implements `LanguageProvider` (`language-provider.ts`). Key fields
| `typeConfig` | Type annotation extraction rules |
| `mroStrategy` | `first-wins` / `c3` / `none` |
| `descriptionExtractor` | Optional hook returning a symbol's doc-comment text as its `description`; feeds the embedding metadata header so doc-only terms are semantically searchable (issue #2270). Most languages register `createLeadingDocDescriptionExtractor` (shared, language-neutral; per-language comment/wrapper config passed at the call site) |
| `definitionPropertiesExtractor` | Optional language-owned hook for structured, clone-safe definition metadata. Shared ingestion persists these properties opaquely; the owning provider supplies the extraction semantics. |
16 providers in `languages/index.ts` via `satisfies Record<SupportedLanguages, LanguageProvider>` — missing a language is a compile error.

View file

@ -70,10 +70,9 @@ This project is indexed by GitNexus as **GitNexus** (248612 symbols, 565510 rela
- **MUST run impact analysis before editing.** Use `impact({target: "symbolName", direction: "upstream"})` (MCP) or `node .gitnexus/run.cjs impact "symbolName" --direction upstream --repo .` (CLI fallback); report callers, processes, and risk. Never substitute grep for graph analysis. For unified PDG impact, add `mode: "pdg"` with optional `line: <N>` — it returns statement-level `affectedStatements` over CDG + REACHING_DEF and inter-procedural symbols in `interproceduralByDepth`/`byDepth`; no-layer/degraded PDG results are UNKNOWN-risk notes (`--pdg` layer). CLI equivalent: `node .gitnexus/run.cjs impact "symbolName" --direction upstream --mode pdg --line <N> --repo .`.
- **MUST analyze graph changes before committing.** Use `detect_changes({scope: "all"})` (MCP) or `node .gitnexus/run.cjs detect-changes --scope all --repo .` (CLI fallback). `partial: true` or `truncated: true` is not a clean check — a zero means unseen, not unaffected; re-run it. For regression review: `detect_changes({scope: "compare", base_ref: "main"})` or `node .gitnexus/run.cjs detect-changes --scope compare --base-ref "main" --repo .`.
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
- MUST warn on HIGH/CRITICAL `risk` pre-edit; never use `riskSharedAxes` to waive a HIGH/CRITICAL `risk` warning. Compare File/symbol: MCP File omits axes; Graph-RAG expands File.
- **MUST treat `risk: UNKNOWN` as unresolved, not as low.** An empty caller set is not evidence the symbol is unused — it can also mean the callers are not resolvable by the index (plain-object property access, dynamic dispatch, cross-language calls). `impact` pairs `UNKNOWN` with a `riskNote` saying so. Confirm with a text search before treating the symbol as safe to change or delete; do not proceed on the strength of a zero.
- When exploring unfamiliar code, use `query({search_query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `context({name: "symbolName"})`.
- **MUST use `query({search_query: "concept"})` for concepts/flows, `context({name: "symbolName"})` for a named symbol, or `impact` for blast radius, on read-only callers, dependencies, imports, or execution flow.** Graph first; text search only for empty/`UNKNOWN`/literals.
- For security review, `explain({target: "fileOrSymbol"})` lists taint findings (source→sink flows; needs `analyze --pdg`).
- For control/data dependence, `pdg_query({mode: "controls", target: "fileOrSymbol"})` answers "under what condition does X run?" (CDG, incl. guard clauses) and `pdg_query({mode: "flows", target, variable})` traces "where does variable Y flow?" (REACHING_DEF). `--pdg` layer.

View file

@ -51,8 +51,9 @@ RUN npm run postinstall --prefix gitnexus
# node:22-bookworm-slim
FROM node:22-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e AS runtime
# curl for the healthcheck; git for cloning; ca-certificates for TLS verification.
RUN apt-get update && apt-get install -y --no-install-recommends curl git ca-certificates && rm -rf /var/lib/apt/lists/* \
# curl for the healthcheck; git for cloning; procps for watch process identity;
# ca-certificates for TLS verification.
RUN apt-get update && apt-get install -y --no-install-recommends curl git procps ca-certificates && rm -rf /var/lib/apt/lists/* \
&& rm -rf /usr/local/lib/node_modules/npm \
&& rm -rf /usr/local/lib/node_modules/corepack \
&& rm -f /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack

View file

@ -52,6 +52,12 @@ Format: **Trigger → Instruction → Reason**. Append new Signs when the same m
- **Do:** Re-run plain `npx gitnexus analyze` — no `--embeddings` flag needed. A retained `embeddingCheckpoint` in the index metadata forces embedding generation for exactly the pending nodes regardless of flags, and clears once they succeed. `--drop-embeddings` abandons the pending nodes instead of retrying them; `--force` also discards the checkpoint (with a warning) and rebuilds without resuming it.
- **Why:** A long analyze run against a flaky HTTP embedding endpoint tolerates bounded sub-batch failures instead of aborting the whole run: it deletes the affected nodes' embedding rows (so they hold zero rows, never a partial set) and records those nodes as pending in `embeddingCheckpoint`. `stats.embeddings` stays an honest, non-zero count of everything that did succeed, so this state never trips the "Embeddings vanished" Sign above — `embedding-checkpoint-pending` is the only reliable signal.
### Scope extraction is incomplete
- **Trigger:** `npx gitnexus status` reports `incompleteReasons: ["scope-extraction-failed"]` when files were omitted, or `incompleteReasons: ["scope-extraction-unverified"]` when the index predates the completeness receipt or its metadata is unreadable. `impact`/`context` reports the same uncertainty as `epistemic: "lower-bound"`; confirmed omissions set `causes.scopeExtractionFiles > 0`.
- **Do:** Re-run `npx gitnexus analyze` (`--force` for a full graph rebuild). If the reason persists, inspect the scope-extraction warnings and treat impact counts as floors until the affected source is supported or corrected.
- **Why:** Parsing continued, but scope captures for the reported file count could not be produced even after the main-thread fallback. Calls, inheritance, imports, or accesses originating there may therefore be absent from the graph.
### Analyze reports INCOMPLETE with a collapsed graph write
- **Trigger:** `npx gitnexus status` reports `incompleteReasons: ["graph-write-collapsed"]`; the analyze summary printed `Repository indexed INCOMPLETELY` naming an expected and a persisted relationship count, and the CLI exited non-zero.
@ -67,8 +73,8 @@ Format: **Trigger → Instruction → Reason**. Append new Signs when the same m
### Wrong repo in multi-repo setups
- **Trigger:** Query/impact results belong to another project.
- **Do:** Call `list_repos`, then pass `repo` on subsequent tools.
- **Why:** Default target is ambiguous when multiple repos are registered.
- **Do:** Confirm an MCP default is configured or the GitNexus process was launched inside the intended registered path without crossing into an unindexed nested Git checkout. Otherwise call `list_repos`, then pass `repo` on subsequent tools; pass it for mutating tools when multiple repos are registered and no MCP default exists.
- **Why:** Read-only tools derive their default from MCP configuration or a process cwd that stays within one registered Git boundary. Outside those paths the target remains ambiguous, and mutating tools stay explicit unless configuration supplies the target.
### LadybugDB lock / "database busy"

139
README.md
View file

@ -1,4 +1,4 @@
# GitNexus (Akon Labs)
# GitNexus (Akon Labs)
**⚠️ Important Notice:** GitNexus has NO official cryptocurrency, token, or coin. Any token/coin using the GitNexus name on Pump.fun or any other platform is **not affiliated with, endorsed by, or created by** this project or its maintainers. Do not purchase any cryptocurrency claiming association with GitNexus.
@ -179,7 +179,7 @@ flowchart TB
| `group_list` | List configured repository groups |
| `group_sync` | Rebuild a group's Contract Registry and cross-repo links |
> Per-repo tools take an optional `repo` parameter (omit it when only one repo is indexed) and an optional `branch` for indexes pinned with `gitnexus analyze --branch`. Omitting `branch` queries the workspace index, which follows your checked-out working tree — switching branches and re-running `gitnexus analyze` updates it incrementally. `explain` and `pdg_query` need an index built with `gitnexus analyze --pdg`.
> Per-repo read-only tools take an optional `repo` parameter. Omit it when only one repo is indexed, an MCP default is configured, or the GitNexus process cwd is inside a registered path without crossing into an unindexed nested Git checkout; otherwise pass it explicitly. Mutating tools require `repo` when multiple repos are indexed and no MCP default exists. Per-repo tools also take an optional `branch` for indexes pinned with `gitnexus analyze --branch`. Omitting `branch` queries the workspace index, which follows your checked-out working tree — switching branches and re-running `gitnexus analyze` updates it incrementally. `explain` and `pdg_query` need an index built with `gitnexus analyze --pdg`.
### Resources for instant context
@ -384,6 +384,7 @@ Everyday commands:
```bash
gitnexus setup # Configure MCP for detected editors (one-time; -c to select)
gitnexus analyze [path] # Index a repository (or update a stale index)
gitnexus analyze [path] --watch # Watch local files and serialize incremental refreshes
gitnexus mcp # Start MCP server (stdio) — serves all indexed repos
gitnexus serve # Start local HTTP server (multi-repo) for web UI connection
gitnexus eval-server # Start lightweight evaluation HTTP tools (loopback by default)
@ -396,6 +397,28 @@ gitnexus uninstall # Preview removal of GitNexus MCP/skills/hooks
You can also query the graph directly from the terminal — `gitnexus query`, `context`, `impact`, `trace`, `cypher`, `detect-changes`, and `check` mirror the MCP tools of the same names, and `gitnexus doctor` prints runtime platform capabilities.
`gitnexus analyze --watch` requires a Git repository. It runs one initial
analysis, then debounces scanner-admitted working-tree changes for 300 ms by
default and applies serialized incremental refreshes. Events arriving during a
refresh remain queued, and retryable failures retain the same batch with bounded
backoff. Invalid `.gitnexusrc` or ignore-file reloads pause ordinary refreshes
until the control file is fixed. Stop the watcher with Ctrl+C.
Watch mode accepts `--debounce`, `--workers`, `--worker-timeout`,
`--max-file-size`, `--branch`, `--pdg`, `--name`, `--allow-duplicate-name`, and
`--verbose`. Explicit one-shot options such as `--force`, `--repair-fts`,
embedding flags, `--skills`, `--self-commit`, `--index-only`, and `--skip-git`
are rejected. Unsupported defaults from `.gitnexusrc` are ignored with a
warning rather than making an otherwise valid repository unwatchable.
POSIX requests clone-first copy-and-swap publication when the live index has no
orphan sidecars. Windows and sidecar fallback runs update in place: failures
known to occur before writes are retried, while a failure that may have mutated
the live index stops the watcher. Watch mode does not pull remotes. Running MCP
and `serve` processes reopen a newly published index automatically; MCP observes
the replacement on its next tool call, typically within five seconds, so no
restart is required.
<details>
<summary><strong>Authenticated <code>eval-server</code> binding</strong></summary>
@ -426,10 +449,13 @@ gitnexus analyze --verbose # Log skipped files when parsers are unavailabl
gitnexus analyze --worker-timeout 60 # Increase worker idle timeout for slow parses
gitnexus analyze --workers <n> # Parse worker pool size (>=1; default: cores-1, capped at 16,
# auto-sized to the repo). 0 is rejected — there is no sequential mode.
gitnexus analyze --spring-actuator ./actuator # Enrich with local Spring Boot Actuator JSON snapshots
gitnexus analyze --wal-checkpoint-threshold 67108864 # LadybugDB WAL auto-checkpoint threshold in bytes
# (default 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB)
```
`--spring-actuator` is explicitly opt-in and accepts either a JSON bundle keyed by `mappings`, `beans`, `conditions`, `configprops`, and/or `env`, or a directory containing endpoint-named JSON files. It confirms matching static nodes and adds conservative runtime-only routes, beans, and property keys. The configured input is excluded from source scanning; only normalized repository-relative exclusions are retained for future scans, never absolute paths. Env/configprops values, origins, condition messages, and source names are never persisted or printed. Because snapshots are external runtime state, an enabled run always rebuilds; the first later run without the option rebuilds once to remove runtime evidence. The same path can be set as `springActuator` in `.gitnexusrc`.
If `analyze` reports a worker parse timeout on a large or unusual repository, it keeps running and falls back safely. To give slow worker jobs more time, use `--worker-timeout 60` or set `GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=60000`. For very large files, `GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES` controls the worker job byte budget.
**Embeddings node limit** — `gitnexus analyze --embeddings` generates semantic search vectors with a default 50,000-node safety cap to protect memory on large repositories:
@ -444,6 +470,46 @@ If embeddings are skipped on a large repository, the indexed graph likely exceed
</details>
<details>
<summary><strong>Keep remote repositories indexed with <code>gitnexus auto-sync</code></strong></summary>
`gitnexus auto-sync` clones or pulls configured repositories, analyzes new commits, and optionally syncs their group. It runs once immediately, then repeats on the configured interval. It runs in the foreground; use your process manager if it must survive a shell session. `gitnexus watch` is reserved and prints this split; it does not start auto-sync or local file watching.
```bash
# 1. Create the config once. It never overwrites an existing file.
gitnexus auto-sync init
# 2. Edit $GITNEXUS_HOME/watch_config.yml, then start it.
gitnexus auto-sync start # `gitnexus auto-sync` is equivalent
gitnexus auto-sync status
gitnexus auto-sync restart # Required after config changes
gitnexus auto-sync stop
gitnexus auto-sync reset # Clear failure state; leaves clones and indexes intact
```
`GITNEXUS_HOME` defaults to `~/.gitnexus`. A minimal configuration:
```yaml
sync_interval_minutes: 10
analyze_timeout: 5m
projects:
- local_path: /absolute/path/to/clones
branches: [main, master]
overwrite_local_changes: false
remote_urls:
- git@github.com:owner/repo.git
```
- `sync_interval_minutes` must be at least `5`; `local_path` must be an absolute path. Clones are stored below it as `host/namespace/repo`.
- Remote URLs must use SSH SCP form and are limited to GitHub, GitLab, or Gitee.
- `branches` are tried in order. The legacy `branch` field is supported, but do not set both.
- Analysis runs in an isolated worker; `analyze_timeout` defaults to, and cannot exceed, half of `sync_interval_minutes`. Timeout and `auto-sync stop` request safe cancellation; a worker in native work exits after reaching a JS-visible safe point. Until then, auto-sync reports `cancelling` or `stopping` and retains ownership so another auto-sync cannot take over, for up to 5 seconds — after that the parent stops waiting and leaves the worker to exit on its own rather than killing it mid-write. This behavior is the same on macOS and Windows. `overwrite_local_changes` defaults to `false`, so a dirty local clone is skipped rather than overwritten; setting it to `true` also deletes untracked files in the clone, while keeping ignored paths.
- Add `group_name` only after creating that group with `gitnexus group create <name>`. Partial clone output is isolated and removed after 14 days.
See the [full auto-sync configuration and runtime reference](gitnexus/README.md#gitnexus-auto-sync) for concurrency, timeouts, failure thresholds, and runtime files.
</details>
<details>
<summary><strong>Repository groups</strong> (multi-repo / monorepo service tracking)</summary>
@ -477,6 +543,7 @@ Commit a `.gitnexusrc` JSON file at the repo root to preconfigure recurring `ana
"skipContextFiles": true, // alias of skipAgentsMd: keep your own AGENTS.md/CLAUDE.md
"skipSkills": true, // don't install standard skill files under .claude/skills/ and .agents/skills/
"embeddings": true, // generate embeddings by default
"springActuator": "./actuator", // optional local runtime snapshot directory or bundle
"workerTimeout": 60,
}
```
@ -491,7 +558,7 @@ Notes:
- The default branch is resolved as: `--default-branch` > `.gitnexusrc` `defaultBranch`/`branch` > auto-detected `origin/HEAD` > `main`.
- `skipContextFiles` / `skipAiContext` are aliases for `skipAgentsMd` — they skip the `AGENTS.md` / `CLAUDE.md` block only. They do **not** imply `skipSkills`. `indexOnly` is the stronger option that skips all file injection.
- Supported keys: `defaultBranch` (`branch`), `skipAgentsMd` (`skipContextFiles`, `skipAiContext`), `skipSkills`, `indexOnly`, `stats`/`noStats`, `embeddings`, `dropEmbeddings`, `name`, `allowDuplicateName`, `maxFileSize`, `workerTimeout`, `walCheckpointThreshold`, `workers`, `embeddingThreads`, `embeddingBatchSize`, `embeddingSubBatchSize`, `embeddingDevice`.
- Supported keys: `defaultBranch` (`branch`), `skipAgentsMd` (`skipContextFiles`, `skipAiContext`), `skipSkills`, `indexOnly`, `stats`/`noStats`, `embeddings`, `dropEmbeddings`, `name`, `allowDuplicateName`, `maxFileSize`, `workerTimeout`, `walCheckpointThreshold`, `workers`, `springActuator`, `embeddingThreads`, `embeddingBatchSize`, `embeddingSubBatchSize`, `embeddingDevice`.
- The file is JSON only. Unknown keys and invalid values fail fast with an actionable error before analysis starts.
</details>
@ -501,36 +568,39 @@ Notes:
Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max-file-size`, `--verbose`). Use the env-var form when you'd otherwise repeat the same flag every run, or when invoking GitNexus from a long-running host (MCP server, eval-server, CI shell) that already manages its own environment. CLI flags take precedence over env vars; env vars take precedence over built-in defaults.
| Variable | Default | Effect | Tune when… |
| ----------------------------------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GITNEXUS_WORKER_POOL_SIZE` | `cores - 1`, capped at 16 | Parse worker pool size (must be ≥ 1). Equivalent to `--workers <n>`. The worker pool is the sole parse path — there is no sequential parser, so `0` is rejected with an actionable error (the pool self-heals via quarantine + respawn). | Constrained containers (cgroup CPU limits) or CI runners with explicit quotas. To narrow down a worker crash set `1` for a single-worker pool — not `0`. |
| `GITNEXUS_PARSE_CHUNK_CONCURRENCY` | `2` | Number of chunks whose file contents may be read into memory in parallel while the pool dispatches the current chunk. Worker dispatch itself stays serial. | Repos large enough to chunk (multi-MB total source) where disk I/O is a measurable fraction of analyze wall-clock. |
| `GITNEXUS_VERBOSE` | unset | When `1`, enables verbose ingestion logs (skipped-file warnings, per-chunk throughput, parse-cache stats). Equivalent to `--verbose`. | Debugging an analyze that "completed" but seems to have missed files; tuning `--workers` / chunk concurrency against observable throughput. |
| `GITNEXUS_AUTH_TOKEN` | unset | Bearer token required when `eval-server` binds beyond loopback. May also be read from `.env.local` or `.env`; shell values take precedence. | Exposing the evaluation HTTP tools to a container, VM, or LAN. |
| `GITNEXUS_PROFILE_DEFERRED` | unset | When `1`, emits `[deferred-profile]` timing/progress logs for the post-chunk deferred resolution band (imports → heritage → buildHeritageMap → legacy call resolution). Implied by `GITNEXUS_VERBOSE`. | Diagnosing analyze stalls in "Resolving calls (all chunks)" on large Java/Kotlin repos (issue #1741) without the full verbose ingestion noise. |
| `GITNEXUS_PROFILE_DEFERRED_SLOW_MS` | `3000` (verbose) / `5000` | Per-file threshold in ms above which `processCallsFromExtracted` emits a `slow file …` log line. Parsed via `Number()`: accepts integers (`5000`), scientific notation (`2.5e3`), decimals (`.5`), and hex (`0x10`). Non-finite or non-positive values fall back to the default. | Hunting a few outlier files dominating the deferred call-resolution stage; lower to surface more, raise to focus only on the worst. |
| `PROF_LBUG_LOAD` | unset | When `1`, emits one `[lbug-load prof]` summary line per `loadGraphToLbug` call breaking the graph-DB persistence wall into stages (`csv-emit` / `copy-nodes` / `copy-rels` / `fallback` / `total`) plus node & edge counts. Zero-cost when unset. | Attributing large-repo analyze wall time across CSV generation vs. LadybugDB `COPY` (issue #2203) — the analyze "emit" timing is the scope-resolution bucket, not this DB-write path. |
| `GITNEXUS_MAX_FILE_SIZE` | `512` (KB) | Walker skip threshold in KB. Hard cap is `32768` (tree-sitter buffer ceiling). Equivalent to `--max-file-size <kb>`. | Indexing repos with intentionally-large source files (generated parsers, vendored bundles) that should still be parsed. |
| `GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS` | `30000` | Worker idle timeout in milliseconds before retry/fallback. Equivalent to `--worker-timeout <seconds>` × 1000. | Slow-parsing files (large minified JS, deeply-nested TS types) that legitimately need more than 30s. |
| `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). 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. |
| `GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS` | `5 × subBatchTimeoutMs` | Total retry wall-time budget per job before quarantining. Combined with `timeoutBackoffFactor`, prevents exponentially-growing retries from stalling for hours. | Slow files that legitimately need long total retry windows; lower to fail-fast on stalls. |
| `GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD` | `max(3, poolSize)` | Per-slot consecutive deaths before the pool's circuit breaker trips. After tripping, every subsequent dispatch rejects until a fresh pool is created. | Hosts where a SIGSEGV-prone native grammar should trip the breaker sooner; CI runners that should fail loudly. |
| `GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS` | `30000` | Max wait at pool shutdown for a retired worker still inside native code. The worker is terminated at its next JS-safe point instead of mid-native-call (which aborts the whole process with `Napi::Error`, #2432); on expiry it is left running, unref'd, and terminated when it surfaces. | Shutdown latency matters more than draining a wedged worker (lower), or a legitimately-slow native grammar needs longer to surface (raise). |
| `GITNEXUS_CPP_CAPTURE_BUDGET_MS` | `20000` | Per-file wall-clock budget for C++ capture extraction. On breach the file keeps the captures accumulated so far and logs a warning — the worker returns to JS instead of stalling in native-heavy loops (#2432). `0` expires immediately. | Pathological generated C++ that still exceeds the budget after the indexed lookups; raise for completeness, lower to fail-fast. |
| `GITNEXUS_CHUNK_BYTE_BUDGET` | `2097152` (2 MB) | Chunk boundary used for cache-key composition and dispatch. Smaller = finer-grained cache hits but more dispatch overhead. | Tuning incremental-analyze cache behavior on monorepos. |
| `GITNEXUS_NO_GITIGNORE` | unset | When set, skips `.gitignore` parsing. `.gitnexusignore` is still honored. | Indexing a repo whose `.gitignore` excludes files you actually want indexed (e.g., generated code committed for cross-repo lookup). |
| `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` | unset | When `=1` strictly, skips the vendored grammar materialize for `tree-sitter-dart`, `tree-sitter-proto`, `tree-sitter-swift`, and `tree-sitter-kotlin` at install time (and the Dart/Proto source builds). Those four won't be parsed; the install still succeeds. | Installing on a host without a C++ toolchain or where the vendored prebuilds don't match; willing to skip Dart/Proto/Swift/Kotlin parsing. |
| `GITNEXUS_MCP_READ_ONLY` | unset | Set to `1` to expose only proven single-repository read tools and resources; `0` disables the policy and any other value fails startup. | The MCP server runs in an environment where graph mutation, raw Cypher, and cross-repository group routing must be unavailable. |
| `GITNEXUS_MCP_ALLOWED_REPOS` | unset | Comma-separated allowlist of canonical indexed repository names or absolute paths. Invalid, ambiguous, or blank entries fail startup. | One MCP process must expose only a bounded subset of the repositories in the global registry. |
| `GITNEXUS_MCP_DEFAULT_REPO` | unset | Canonical indexed repository name or absolute path used when a tool or resource omits its repository. Must belong to the allowlist when one is set. | Several repositories are available but unqualified MCP calls should resolve deterministically. |
| `GITNEXUS_MCP_DEFAULT_MAX_TOKENS` | unset | Default positive-integer response budget for MCP `query`, `context`, and `impact`, estimated at four UTF-8 bytes per token. Explicit `maxTokens` wins. | Long MCP responses consume too much model context and callers cannot reliably add a per-request budget. |
| `GITNEXUS_PUBLIC_ORIGIN` | unset | The single browser origin `serve` is reached through, added to the CORS allowlist and to the write-route origin guard. A wildcard bind (`0.0.0.0`) has no host identity, so without this the server's own UI is refused. **Setting it currently refuses to start:** `serve` has no authentication, requests carrying no `Origin` header already reach `POST /api/analyze` and `DELETE /api/repo`, and this is the setting that would admit browser writes on top of that. Matching rules for when the gate lifts: the hostname must match exactly, and so must the scheme. A value with no scheme (`app.example.com`) means `https`, since a bare host comes from platform service discovery and those terminate TLS; spell out `http://app.example.com` for plain HTTP. An explicit port must match; with no port, any port on that hostname is accepted. Anything that is not one reachable host (a list, `*`, a bare port number, a `:0` port, a trailing dot) warns at startup and allows nothing. | `gitnexus serve` runs behind a reverse proxy or on a wildcard bind, and the UI's index/delete requests return `origin_not_allowed`. |
| Variable | Default | Effect | Tune when… |
| ----------------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GITNEXUS_WORKER_POOL_SIZE` | `cores - 1`, capped at 16 | Parse worker pool size (must be ≥ 1). Equivalent to `--workers <n>`. The worker pool is the sole parse path — there is no sequential parser, so `0` is rejected with an actionable error (the pool self-heals via quarantine + respawn). | Constrained containers (cgroup CPU limits) or CI runners with explicit quotas. To narrow down a worker crash set `1` for a single-worker pool — not `0`. |
| `GITNEXUS_PARSE_CHUNK_CONCURRENCY` | `2` | Number of chunks whose file contents may be read into memory in parallel while the pool dispatches the current chunk. Worker dispatch itself stays serial. | Repos large enough to chunk (multi-MB total source) where disk I/O is a measurable fraction of analyze wall-clock. |
| `GITNEXUS_VERBOSE` | unset | When `1`, enables verbose ingestion logs (skipped-file warnings, per-chunk throughput, parse-cache stats). Equivalent to `--verbose`. | Debugging an analyze that "completed" but seems to have missed files; tuning `--workers` / chunk concurrency against observable throughput. |
| `GITNEXUS_ANALYZER_IDENTITY_IN_PROCESS_GUARDS` | unset | When truthy (`1`/`true`/`yes`), forces in-process cache-guard validation once a batch has ≥128 requests. In-process mode also auto-selects when `packageRoot`/`buildRoot` fail `W_OK` with `EACCES`/`EROFS`. Otherwise those large batches use a Node subprocess probe. Batches under 128 always stay in-process. | Trusted or read-only installs where two identity subprocess spawns per analyze dominate wall time; leave unset to keep the default isolation path on writable trees. |
| `GITNEXUS_RESOLVE_DEF_GRAPH_ID_MEMO` | on (unset) | Memoizes `resolveDefGraphId` per `nodeLookup` instance (WeakMap). Enabled by default. Set to `0`/`false`/`off`/`no` to disable and recompute on every call (debug / bisect memo bugs). | Suspecting stale graph-id resolution after a lookup rebuild, or comparing memo vs uncached cost on a large index. |
| `GITNEXUS_AUTH_TOKEN` | unset | Bearer token required when `eval-server` binds beyond loopback. May also be read from `.env.local` or `.env`; shell values take precedence. | Exposing the evaluation HTTP tools to a container, VM, or LAN. |
| `GITNEXUS_MCP_AUTH_TOKEN` | unset | Bearer token for the dedicated `gitnexus mcp --http` server, for a **directly reachable** `gitnexus serve` `/api/mcp` route, and for the `docker-server` / web proxy in front of one. A non-loopback dedicated MCP bind requires it; `serve` enables protocol-layer MCP auth when it is set. Behind a proxy, set the **same** value on both services: the proxy spends the edge `GITNEXUS_SERVE_AUTH_TOKEN`, then replaces `Authorization` with this token on `/api/mcp` only. | Dedicated MCP, a `serve` the client can reach directly, or a proxied deploy (Render Blueprint) where the backend runs protocol-layer MCP auth — configure it on the proxy too. |
| `GITNEXUS_PROFILE_DEFERRED` | unset | When `1`, emits `[deferred-profile]` timing/progress logs for the post-chunk deferred resolution band (imports → heritage → buildHeritageMap → legacy call resolution). Implied by `GITNEXUS_VERBOSE`. | Diagnosing analyze stalls in "Resolving calls (all chunks)" on large Java/Kotlin repos (issue #1741) without the full verbose ingestion noise. |
| `GITNEXUS_PROFILE_DEFERRED_SLOW_MS` | `3000` (verbose) / `5000` | Per-file threshold in ms above which `processCallsFromExtracted` emits a `slow file …` log line. Parsed via `Number()`: accepts integers (`5000`), scientific notation (`2.5e3`), decimals (`.5`), and hex (`0x10`). Non-finite or non-positive values fall back to the default. | Hunting a few outlier files dominating the deferred call-resolution stage; lower to surface more, raise to focus only on the worst. |
| `PROF_LBUG_LOAD` | unset | When `1`, emits one `[lbug-load prof]` summary line per `loadGraphToLbug` call breaking the graph-DB persistence wall into stages (`csv-emit` / `copy-nodes` / `copy-rels` / `fallback` / `total`) plus node & edge counts. Zero-cost when unset. | Attributing large-repo analyze wall time across CSV generation vs. LadybugDB `COPY` (issue #2203) — the analyze "emit" timing is the scope-resolution bucket, not this DB-write path. |
| `GITNEXUS_MAX_FILE_SIZE` | `512` (KB) | Walker skip threshold in KB. Hard cap is `32768` (tree-sitter buffer ceiling). Equivalent to `--max-file-size <kb>`. | Indexing repos with intentionally-large source files (generated parsers, vendored bundles) that should still be parsed. |
| `GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS` | `30000` | Worker idle timeout in milliseconds before retry/fallback. Equivalent to `--worker-timeout <seconds>` × 1000. | Slow-parsing files (large minified JS, deeply-nested TS types) that legitimately need more than 30s. |
| `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). 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. |
| `GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS` | `5 × subBatchTimeoutMs` | Total retry wall-time budget per job before quarantining. Combined with `timeoutBackoffFactor`, prevents exponentially-growing retries from stalling for hours. | Slow files that legitimately need long total retry windows; lower to fail-fast on stalls. |
| `GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD` | `max(3, poolSize)` | Per-slot consecutive deaths before the pool's circuit breaker trips. After tripping, every subsequent dispatch rejects until a fresh pool is created. | Hosts where a SIGSEGV-prone native grammar should trip the breaker sooner; CI runners that should fail loudly. |
| `GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS` | `30000` | Max wait at pool shutdown for a retired worker still inside native code. The worker is terminated at its next JS-safe point instead of mid-native-call (which aborts the whole process with `Napi::Error`, #2432); on expiry it is left running, unref'd, and terminated when it surfaces. | Shutdown latency matters more than draining a wedged worker (lower), or a legitimately-slow native grammar needs longer to surface (raise). |
| `GITNEXUS_CPP_CAPTURE_BUDGET_MS` | `20000` | Per-file wall-clock budget for C++ capture extraction. On breach the file keeps the captures accumulated so far and logs a warning — the worker returns to JS instead of stalling in native-heavy loops (#2432). `0` expires immediately. | Pathological generated C++ that still exceeds the budget after the indexed lookups; raise for completeness, lower to fail-fast. |
| `GITNEXUS_CHUNK_BYTE_BUDGET` | `2097152` (2 MB) | Per-bucket byte budget for parse-cache packing. Files are grouped by `(language, hash(path) mod 128)`; packs inside a bucket are cut at this limit. Smaller = finer-grained invalidation and more dispatch. Default is always 2 MiB and no longer scales with worker count. | Tuning incremental-analyze cache invalidation on monorepos without changing `--workers`. |
| `GITNEXUS_NO_GITIGNORE` | unset | When set, skips `.gitignore` parsing. `.gitnexusignore` is still honored. | Indexing a repo whose `.gitignore` excludes files you actually want indexed (e.g., generated code committed for cross-repo lookup). |
| `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` | unset | When `=1` strictly, skips the vendored grammar materialize for `tree-sitter-dart`, `tree-sitter-proto`, `tree-sitter-swift`, and `tree-sitter-kotlin` at install time (and the Dart/Proto source builds). Those four won't be parsed; the install still succeeds. | Installing on a host without a C++ toolchain or where the vendored prebuilds don't match; willing to skip Dart/Proto/Swift/Kotlin parsing. |
| `GITNEXUS_MCP_READ_ONLY` | unset | Set to `1` to expose only proven single-repository read tools and resources; `0` disables the policy and any other value fails startup. | The MCP server runs in an environment where graph mutation, raw Cypher, and cross-repository group routing must be unavailable. |
| `GITNEXUS_MCP_ALLOWED_REPOS` | unset | Comma-separated allowlist of canonical indexed repository names or absolute paths. Invalid, ambiguous, or blank entries fail startup. | One MCP process must expose only a bounded subset of the repositories in the global registry. |
| `GITNEXUS_MCP_DEFAULT_REPO` | unset | Canonical indexed repository name or absolute path used when a tool or resource omits its repository. Must belong to the allowlist when one is set. | Several repositories are available but unqualified MCP calls should resolve deterministically. |
| `GITNEXUS_MCP_DEFAULT_MAX_TOKENS` | unset | Default positive-integer response budget for MCP `query`, `context`, and `impact`, estimated at four UTF-8 bytes per token. Explicit `maxTokens` wins. | Long MCP responses consume too much model context and callers cannot reliably add a per-request budget. |
| `GITNEXUS_PUBLIC_ORIGIN` | unset | The single browser origin `serve` is reached through, added to the CORS allowlist and to the write-route origin guard. A wildcard bind (`0.0.0.0`) has no host identity, so without this the server's own UI is refused. **Setting it currently refuses to start:** `serve` has no authentication, requests carrying no `Origin` header already reach `POST /api/analyze` and `DELETE /api/repo`, and this is the setting that would admit browser writes on top of that. Matching rules for when the gate lifts: the hostname must match exactly, and so must the scheme. A value with no scheme (`app.example.com`) means `https`, since a bare host comes from platform service discovery and those terminate TLS; spell out `http://app.example.com` for plain HTTP. An explicit port must match; with no port, any port on that hostname is accepted. Anything that is not one reachable host (a list, `*`, a bare port number, a `:0` port, a trailing dot) warns at startup and allows nothing. | `gitnexus serve` runs behind a reverse proxy or on a wildcard bind, and the UI's index/delete requests return `origin_not_allowed`. |
| `GITNEXUS_TRUST_PROXY` | `loopback, linklocal, uniquelocal` | Express `trust proxy` value — which upstream hops may set `X-Forwarded-*`, and so what the per-IP rate limiter reads as the client IP. Set it to the exact number of proxies you control. Every hop past that is one more entry of the chain the caller gets to write. `false`/`no`/`off` (and a `0` hop count) trust no hop; a proxy list Express can compile (`loopback`, `10.0.0.0/8, 127.0.0.1`) names them instead. `true`/`yes`/`on` is **rejected**: it reads the client-controlled leftmost `X-Forwarded-For` entry, so a spoofed chain earns a fresh rate-limit key per request, and express-rate-limit rejects it too (`ERR_ERL_PERMISSIVE_TRUST_PROXY`). Counts above `16` are rejected as well, as a sanity ceiling rather than a safety boundary. Any invalid value warns and falls back to the default. Bind non-loopback with this unset and `serve` warns: a load balancer outside the private ranges is untrusted, so every request keys to the balancer and the per-IP limit becomes one shared limit. | `serve` sits behind a load balancer outside the private ranges (AWS ALB, Cloudflare, CGNAT), where every request otherwise collapses to the proxy hop and rate limiting goes global. |
</details>
@ -590,7 +660,7 @@ GitNexus builds a complete knowledge graph of your codebase through a multi-phas
GitNexus uses a **global registry** so one MCP server can serve multiple indexed repos. No per-project MCP config needed — set it up once and it works everywhere.
Each `gitnexus analyze` stores the index in `.gitnexus/` inside the repo (portable, gitignored) and registers a pointer in `~/.gitnexus/registry.json`. When an AI agent starts, the MCP server reads the registry and can serve any indexed repo. LadybugDB connections are opened lazily on first query and evicted after 5 minutes of inactivity (max 5 concurrent). If only one repo is indexed, the `repo` parameter is optional on all tools — agents don't need to change anything.
Each `gitnexus analyze` stores the index in `.gitnexus/` inside the repo (portable, gitignored) and registers a pointer in `~/.gitnexus/registry.json`. When an AI agent starts, the MCP server reads the registry and can serve any indexed repo. LadybugDB connections are opened lazily on first query and evicted after 5 minutes of inactivity (max 5 concurrent). Read-only tools can omit `repo` when only one repo is indexed, an MCP default is configured, or the GitNexus process cwd is inside a registered path without crossing into an unindexed nested Git checkout. Outside those paths—and for mutating tools with multiple indexed repos and no MCP default—pass `repo` explicitly.
<details>
<summary><strong>Architecture diagram</strong></summary>
@ -768,6 +838,7 @@ gitnexus wiki
# Use a custom model or provider (default model: minimax/minimax-m2.5)
gitnexus wiki --model gpt-4o
gitnexus wiki --base-url https://api.anthropic.com/v1
gitnexus wiki --provider grok # local Grok Build CLI (uses `grok login`, no API key)
# Force full regeneration
gitnexus wiki --force

View file

@ -46,6 +46,17 @@ npx gitnexus status
npx gitnexus list
```
**Scope extraction incomplete:** `npx gitnexus status` reports
`incompleteReasons: ["scope-extraction-failed"]` when one or more files still
lack scope captures after the worker and fallback passes. `impact` and `context`
then report a lower bound with `causes.scopeExtractionFiles` set to the affected
file count. Re-run `npx gitnexus analyze --force`; if the reason remains, inspect
the scope-extraction warnings for the unsupported or malformed source file.
Every pre-existing index remains unverified until it is analyzed once by a
version that writes the completeness receipt. An older index or unreadable completeness record reports
`incompleteReasons: ["scope-extraction-unverified"]`; re-analyze it before treating
empty impact results as exact.
---
## Embeddings

View file

@ -59,11 +59,16 @@ The `render.yaml` Blueprint (see the README's **Deploy to Render**) puts `gitnex
- **The generated `GITNEXUS_SERVE_AUTH_TOKEN` is the only access control.** The proxy rejects any `/api/*` request without it with a `401` before forwarding. Rotate it by editing the environment variable on the `gitnexus-web` service and redeploying.
- **The CSRF guard is inert on this path.** The proxy strips `Origin` before forwarding, so the server's write-origin guard does nothing for proxied traffic — it passes `Origin`-less requests through by design. The token is not a second layer behind the guard.
- **Anyone holding the token can read every indexed repo's source.** These routes carry no origin guard, and the first three carry no rate limiter either: `GET /api/repos`, `GET /api/graph`, `POST /api/query`, `GET /api/file`, `GET /api/grep`. Whoever has the token can also index and delete repositories.
- **`POST /api/mcp` rides the same path.** `serve` mounts the MCP handler via `mountMCPEndpoints`, and `createStreamableHttpHandler` is called with no `authToken` — a **pre-existing** gap in `serve` itself, not something this deploy introduces. On Render it is closed only by the edge token and the private network. A `serve` bound directly to a public interface has no such cover.
- **`POST /api/mcp` rides the same path.** When `GITNEXUS_MCP_AUTH_TOKEN` is set on the backend, `serve` protects `/api/mcp` with the same constant-time Bearer check as the dedicated HTTP MCP server, before parsing the request body. The Render Blueprint does not set a backend MCP token by default. To enable it behind the proxy, set the **same** `GITNEXUS_MCP_AUTH_TOKEN` on both the `gitnexus-web` proxy and the `gitnexus-server` backend: the proxy consumes the edge `GITNEXUS_SERVE_AUTH_TOKEN`, then replaces `Authorization` with the MCP token on `/api/mcp` (and its subpaths) only — the edge credential is never forwarded, and other `/api/*` routes stay stripped. Configuring it on the backend alone makes every proxied MCP request `401`.
- **A directly reachable `serve` still needs an explicit control.** If neither `GITNEXUS_MCP_AUTH_TOKEN` nor an authenticated edge/private-network boundary is present, `/api/mcp` is unauthenticated. Do not bind that topology to a LAN or public interface: MCP readers can access indexed source and graph context.
- **Rate limits bound cost, not access.** They cap what a token holder can spend; they do not decide who gets in.
Do not hand the URL out as a public demo. A token holder has read access to everything the deploy has indexed.
### `/api/grep` regex semantics and residual ReDoS exposure
`GET /api/grep` executes caller-supplied patterns as real regular expressions (with an optional path-substring `fileFilter` and `caseSensitive` flag) to honor the web chat's grep tool contract; `literal=1` restores the older escaped-substring mode. Mitigations: a 200-character pattern cap, line-by-line matching, a max-200 result cap, and a 5-second wall-clock budget. Matching runs in a `worker_threads` worker so a catastrophic pattern (e.g. `(a+)+$`) can be killed with `terminate()` when the budget expires — the parent event loop (other routes + SSE) stays responsive. A timed-out scan returns partial results with `timedOut: true`; the web grep tool surfaces that flag so an agent does not treat a cut-off scan as exhaustive. CodeQL still flags constructing a `RegExp` from the query string; that is the advertised contract, not accidental injection. Hosted deploys continue to gate the route behind the edge token.
## Automated Scans Running in CI
This repository runs the following scans automatically. Findings appear under the repository's **Security → Code scanning** tab.

View file

@ -112,6 +112,14 @@ const upstreamOrigin = upstreamBase ? new URL(upstreamBase).origin : null;
// (gitnexus/src/mcp/http-transport.ts).
const authToken = process.env.GITNEXUS_SERVE_AUTH_TOKEN?.trim() || null;
// The protocol-layer credential the upstream `serve` expects on /api/mcp when it
// runs with MCP Bearer auth enabled. Set it to the SAME value on both services:
// the edge token is spent here and replaced with this one for MCP requests only
// (see proxyToUpstream). Unset — the default — means no injection, so a backend
// without MCP auth is unaffected. Blank-is-absent follows resolveAuthToken
// (gitnexus/src/mcp/http-transport.ts). Never logged.
const mcpAuthToken = process.env.GITNEXUS_MCP_AUTH_TOKEN?.trim() || null;
// Mirrors the non-loopback refusal in http-transport.ts (startMcpHttpServer),
// relocated because the trust boundary is here: an unguarded `serve` behind a
// private service is legitimate, an unguarded public proxy is not.
@ -341,11 +349,17 @@ async function proxyToUpstream(req, res) {
// talks to this same-origin web service.
delete headers.origin;
delete headers.referer;
// The edge token is spent here. `serve` reads no Authorization header
// (gitnexus/src/server/mcp-http.ts mounts /api/mcp unguarded), so forwarding
// it would only copy a live credential into another service's logs. Pinned by
// test.
// The edge token is spent here and must never be forwarded: copying
// Authorization would put a live credential into another service's logs. So
// drop it unconditionally first, then — for the MCP route alone, and only
// when a backend token is configured — replace it with that separate
// protocol credential. Unset GITNEXUS_MCP_AUTH_TOKEN (the default) leaves
// every request stripped, as before. The scope is the normalized pathname,
// so a query string can't widen it and /api/mcpfoo doesn't qualify.
delete headers.authorization;
const upstreamPath = upstream.pathname;
const isMcpRoute = upstreamPath === '/api/mcp' || upstreamPath.startsWith('/api/mcp/');
if (isMcpRoute && mcpAuthToken) headers.authorization = `Bearer ${mcpAuthToken}`;
headers.host = upstream.host;
// Replace, never forward, the inbound chain (see clientAddressFor).
const clientAddress = clientAddressFor(req);

View file

@ -271,6 +271,12 @@ it('does not inject config into static assets', async () => {
const TEST_AUTH_TOKEN = 'proxy-test-token-0123456789abcdefghij';
const TEST_BEARER = `Bearer ${TEST_AUTH_TOKEN}`;
// The protocol token the upstream expects on /api/mcp. Deliberately unlike the
// edge token, so "injected the backend credential" and "forwarded the edge one"
// can never both satisfy an assertion.
const TEST_MCP_TOKEN = 'backend-mcp-token-0123456789abcdefghij';
const TEST_MCP_BEARER = `Bearer ${TEST_MCP_TOKEN}`;
// rawRequest never sends credentials; apiRequest does. In a file whose subject
// is who gets let through, no test should pass because a helper quietly
// authenticated for it.
@ -376,6 +382,11 @@ async function withProxy(
const proc = spawnServerWithEnv(dir, port, {
GITNEXUS_UPSTREAM_URL: schemeless ? target : `http://${target}`,
GITNEXUS_SERVE_AUTH_TOKEN: TEST_AUTH_TOKEN,
// An ambient GITNEXUS_MCP_AUTH_TOKEN in the developer's shell would make the
// proxy inject one on /api/mcp, so drop it: spawn omits undefined entries,
// which unsets the inherited value. A test that wants injection sets it via
// `env` below.
GITNEXUS_MCP_AUTH_TOKEN: undefined,
...env,
});
proc.stderr.setEncoding('utf8');
@ -969,8 +980,9 @@ it('forwards an /api/* request that carries the correct token', async () => {
});
it('strips the Authorization header instead of forwarding the edge token', async () => {
// The token is spent at this hop. `serve` reads no Authorization header, so
// forwarding would only copy a live credential into another service's logs.
// The edge credential is spent and stripped at this hop. Forwarding it
// would copy a live credential into another service's logs. With no
// GITNEXUS_MCP_AUTH_TOKEN configured — the default — nothing replaces it.
await withProxy({}, async (port, ctx) => {
const res = await apiRequest(port, '/api/mcp', { method: 'POST', body: '{}' });
assert.equal(res.status, 200, 'the request itself must still be proxied');
@ -978,6 +990,72 @@ it('strips the Authorization header instead of forwarding the edge token', async
});
});
// -- Upstream MCP token injection (GITNEXUS_MCP_AUTH_TOKEN) -----------------
//
// A backend running protocol-layer MCP auth expects its own Bearer on
// /api/mcp, and the edge credential can't serve as one. Both services are
// configured with the same GITNEXUS_MCP_AUTH_TOKEN; this hop spends the edge
// token and substitutes the backend one, for that route only.
// Stands in for a `serve` with MCP Bearer auth enabled: only the exact backend
// credential gets through, so a passing two-hop request proves what was sent.
const mcpBackend = (req, res) => {
if (req.headers.authorization !== TEST_MCP_BEARER) {
res.writeHead(401, { 'Content-Type': 'application/json; charset=utf-8' });
res.end('{"error":"unauthorized"}');
return;
}
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
res.end('{"ok":true}');
};
it('treats a blank GITNEXUS_MCP_AUTH_TOKEN as unset and still strips', async () => {
const env = { GITNEXUS_MCP_AUTH_TOKEN: ' ' };
await withProxy({ env }, async (port, ctx) => {
const res = await apiRequest(port, '/api/mcp', { method: 'POST', body: '{}' });
assert.equal(res.status, 200);
assert.equal(ctx.received.headers.authorization, undefined);
});
});
it('replaces the edge credential with the upstream MCP token on /api/mcp', async () => {
const env = { GITNEXUS_MCP_AUTH_TOKEN: TEST_MCP_TOKEN };
await withProxy({ upstream: mcpBackend, env }, async (port, ctx) => {
const res = await apiRequest(port, '/api/mcp', { method: 'POST', body: '{}' });
assert.equal(res.status, 200, 'a backend that demands the MCP token must accept this hop');
assert.equal(ctx.received.headers.authorization, TEST_MCP_BEARER);
assert.notEqual(
ctx.received.headers.authorization,
TEST_BEARER,
'the edge credential must never be forwarded',
);
});
});
it('injects the upstream MCP token on /api/mcp subpaths and ignores the query string', async () => {
const env = { GITNEXUS_MCP_AUTH_TOKEN: TEST_MCP_TOKEN };
await withProxy({ upstream: mcpBackend, env }, async (port, ctx) => {
for (const path of ['/api/mcp/messages', '/api/mcp?session=abc']) {
const res = await apiRequest(port, path, { method: 'POST', body: '{}' });
assert.equal(res.status, 200, `${path} must reach the MCP backend authenticated`);
assert.equal(ctx.received.headers.authorization, TEST_MCP_BEARER, path);
}
});
});
it('leaves non-MCP routes stripped when an upstream MCP token is configured', async () => {
// /api/mcpfoo shares a prefix with the MCP route but is not it, and a plain
// API route never carries a protocol credential.
const env = { GITNEXUS_MCP_AUTH_TOKEN: TEST_MCP_TOKEN };
await withProxy({ env }, async (port, ctx) => {
for (const path of ['/api/mcpfoo', '/api/health']) {
const res = await apiRequest(port, path);
assert.equal(res.status, 200);
assert.equal(ctx.received.headers.authorization, undefined, path);
}
});
});
it('never gates static assets behind the token', async () => {
// The UI has to load before it can prompt for a token.
await withProxy({}, async (port, ctx) => {

View file

@ -0,0 +1,312 @@
# GitNexus Engineering Plan
> Task: Fix #3075 — File `impact` risk is not comparable to Function/Method risk.
> Evidence verified at commit `6bff33d14cbfe1e7b4f04bca51507e9f64ef579c` (`feat/kotlin-const-resolver`); GitNexus index 129 commits behind, refresh skipped: full-repo `--index-only --pdg` rebuild is impractical this session. Scorer and schema claims are `[verified]` from source; live inversion numbers are `[graph]` on the stale index.
## 1. Objective
Make File vs symbol `impact.risk` honest for consumers: either they can tell the scales differ, or they can compare on a shared two-axis score. Do **not** DEFINES-bridge processes/modules onto File targets (issue reporter sampled 8/10 one-importer files jumping to HIGH/CRITICAL). Do **not** retune Function HIGH/CRITICAL thresholds (agent warn-before-edit).
Acceptance:
- A File with a wider blast radius than a Function in the same file no longer looks “safer” when a consumer only reads `risk`, **or** the result states that `risk` is not comparable across kinds and offers `riskSharedAxes` for comparison.
- File targets still cannot trip HIGH/CRITICAL via `processes_affected` / `modules_affected` unless those axes become real in the index (they are not today).
- Existing Function/Method labels under the current four-axis ladder stay the same for the same inputs.
- MCP `riskNote` remains UNKNOWN-only (`tools.ts` contract).
## 2. Current Behaviour
Callgraph `impact` ends in `LocalBackend._runImpactBFS` (`gitnexus/src/mcp/local/local-backend.ts`). After BFS it enriches impacted ids with `STEP_IN_PROCESS` and `MEMBER_OF`, then scores:
```7720:7738:gitnexus/src/mcp/local/local-backend.ts
} else if (
directCount >= 30 ||
processCount >= 5 ||
moduleCount >= 5 ||
impacted.length >= 200
) {
risk = 'CRITICAL';
} else if (
directCount >= 15 ||
processCount >= 3 ||
moduleCount >= 3 ||
impacted.length >= 100
) {
risk = 'HIGH';
} else if (directCount >= 5 || impacted.length >= 30) {
risk = 'MEDIUM';
} else {
risk = 'LOW';
}
```
Empty upstream → `UNKNOWN` + `riskNote`. Downstream empty stays LOW. `skipEnrichment` (ambiguous probes) already scores on direct+total only. PDG mode forces `risk: UNKNOWN` (`composeUnifiedPdgImpactResult`) — out of scope.
File BFS walk is mostly File←IMPORTS File. Enrichment queries those File ids. Processes are CALLS traces (`process-processor.ts`); communities admit only Function/Class/Method/Interface (`isCommunitySymbol` in `community-processor.ts:412-416`). File is not in that set. `enrichCandidateLabels` UNION also **omits File**, so File `target.type` is often `""`; detect File via `id` prefix `File:`.
Web Graph RAG (`gitnexus-web/src/core/llm/tools.ts` ~13311346) duplicates the same ladder.
## 3. Relevant Architecture
| Layer | Role |
|---|---|
| Index | File never sources `STEP_IN_PROCESS` / `MEMBER_OF` by construction |
| MCP `_runImpactBFS` | Blast radius + four-axis `risk` |
| Ambiguous probes | `skipEnrichment` → 2-axis `risk` already |
| `mergeRisk` | Group overlay; monotone in crossings; does not know target kind |
| CLI `formatImpactResult` | Prints counts; **does not print `risk`** on the resolved callgraph path; JSON `impactCommand` still ships `risk` |
| `ai-context.ts` / `tools.ts` | Agent contract: warn on HIGH/CRITICAL; `riskNote` UNKNOWN-only |
| Web LLM `impact` | Same formula, prose `RISK:` line |
Modules: Local (MCP), Cli (format/docs), Group (`mergeRisk`), gitnexus-web LLM tools. Shared package `gitnexus-shared` is already a dependency of both CLI and web.
## 4. GitNexus Findings
- Primary: `_runImpactBFS` — d=1 `[graph]` `impact(target:_runImpactBFS, maxDepth:1, includeTests:true)`: `_impactImpl`, `impactByUid`. Production chain `[verified]`: `impact``_impactImpl``_runImpactBFS`; `impactByUid` skips per-symbol process lists but **not** aggregation (`skipPerSymbolEnrichment` only).
- `LocalBackend.impact` d=1 `[graph]` `context`: `callTool`.
- Duplicate scorer `[verified]` grep: `gitnexus-web/src/core/llm/tools.ts`.
- `mergeRisk` `[verified]` callers in `src/`: only `runGroupImpact` (`cross-impact.ts:907`). Graph d=1 listed a test File (`impact-pdg-shape.test.ts`) and missed `runGroupImpact` — trust source.
- Schema `[verified]`: `isCommunitySymbol` excludes File; `schema.ts` documents MEMBER_OF as Function/Class/Method/Interface only.
- Live inversion `[graph]` stale index, `impact summaryOnly` on GitNexus:
| target | kind | impacted | direct | processes | modules | risk |
|---|---|---|---|---|---|---|
| `lbug-config.ts` | File | 54 | 12 | 0 | 0 | MEDIUM |
| `openLbugConnection` | Function | 16 | 9 | 3 | 2 | HIGH |
| `local-backend.ts` | File | 12 | 10 | 0 | 0 | MEDIUM |
| `refreshRepos` | Method | 50 | 5 | 4 | 7 | CRITICAL |
- Clusters/processes resources `[graph]`: Local/Cli/Group sit in the impact path; process traces are function-stepped, not File-stepped.
- Related tests `[verified]`: `test/unit/impact-pagination.test.ts` (CRITICAL from `direct=400`); `test/integration/impact-zero-caller-risk.test.ts` (`withTestLbugDB` seed — pattern to extend); `test/unit/eval-formatters.test.ts` (`formatImpactResult`); group `mergeRisk` tests.
## 5. Statement-Level PDG Findings
PDG unavailable (`pdg_query` on `_runImpactBFS`: “no PDG layer”). Recommend `node .gitnexus/run.cjs analyze --index-only --pdg` before any future statement-slice work. Control flow of the scorer is a straight if/else after enrichment; no hidden guards. `skipEnrichment` is the only branch that structurally zeros process/module counts besides File ids.
## 6. Proposed Changes
### 6.1 Extract `scoreImpactRisk``gitnexus-shared/src/impact-risk.ts` (new)
- **Responsibility:** Pure function: `{ direction, directCount, processCount, moduleCount, impactedCount, unusedAxes }``{ risk, riskSharedAxes, riskScale }`.
- **Behaviour:** Existing UNKNOWN/CRITICAL/HIGH/MEDIUM/LOW thresholds unchanged when `unusedAxes` is empty. `riskSharedAxes` always scores as if `processCount=0` and `moduleCount=0` (UNKNOWN rule still applies). `riskScale.comparableAcrossKinds` is false iff `unusedAxes` is non-empty. `riskScale.unusedAxes` lists `{ axis, reason }`.
- **Constraints:** Zero deps. Export from `gitnexus-shared/src/index.ts`. Do not put MCP types here.
- **File detection:** caller passes unused axes; helper does not parse UIDs.
### 6.2 Wire MCP — `_runImpactBFS` in `local-backend.ts`
- After computing `processCount`/`moduleCount`, set `unusedAxes`:
- target `id` starts with `File:` **or** `symType === 'File'` → processes + modules, reason `file-nodes-have-no-process-or-community-membership`;
- `skipEnrichment` → same axes, reason `enrichment-skipped` (ambiguous probes).
- Replace inline ladder with `scoreImpactRisk`.
- Spread `riskScale` and `riskSharedAxes` on the result next to `risk`. Do **not** set `riskNote` for File.
- Ambiguous candidate summaries: forward the new fields (probes already skip enrichment).
- `target.type` for File: if still `""`, prefer `'File'` when `id` starts with `File:` (display-only; helps CLI).
### 6.3 Web duplicate — `gitnexus-web/src/core/llm/tools.ts`
- Import `scoreImpactRisk` from `gitnexus-shared`. Print `RISK:` from `risk`; if `!comparableAcrossKinds`, one extra line: not comparable to Function risk; shared-axes label is `riskSharedAxes`.
### 6.4 Agent/MCP contract copy
- `gitnexus/src/mcp/tools.ts` impact description: document `riskScale` / `riskSharedAxes`; keep `riskNote` UNKNOWN-only; say File `risk` is not comparable to symbol `risk`.
- `gitnexus/src/cli/ai-context.ts`: HIGH/CRITICAL warning still applies; add: do not rank a File `MEDIUM` below a contained Function `HIGH` without `riskSharedAxes`.
- `formatImpactResult`: on resolved callgraph results with `risk`, print `Risk: {risk}` and, when incomparable, `Shared-axes risk: {riskSharedAxes} (File/process axes unused)`.
### 6.5 Explicitly not changing
- DEFINES-bridge, community/process indexers, `mergeRisk` formula, PDG `UNKNOWN`, `detectChanges` `risk_level`, Function thresholds.
## 7. Implementation Sequence
1. Add `gitnexus-shared` helper + unit table (issue-shaped inputs + UNKNOWN + skipEnrichment). Shared package tests if present; otherwise `gitnexus/test/unit/impact-risk.test.ts` importing the helper.
2. Switch `_runImpactBFS` + candidate probe payload. Tree still coherent: old `risk` values identical for Function fixtures.
3. Integration seed in `impact-zero-caller-risk.test.ts` **or** new `impact-file-risk-scale.test.ts`: File with ≥5 File IMPORTS (MEDIUM on direct) vs Function with 3 process-member callers (HIGH); assert File `riskScale.comparableAcrossKinds === false`, Function true, File `riskSharedAxes === risk`, Function `riskSharedAxes` is LOW/MEDIUM while `risk` is HIGH.
4. CLI formatter + `eval-formatters.test.ts`.
5. `tools.ts` + `ai-context.ts` wording.
6. Web import + a unit assertion on the printed RISK block if a test already covers that tool.
7. `npx tsc --noEmit` in `gitnexus/` and `gitnexus-web/`; `cd gitnexus && npm run test:unit -- test/unit/impact-risk.test.ts test/unit/eval-formatters.test.ts`; integration file from step 3.
## 8. Test Strategy
| File | Scenarios |
|---|---|
| `gitnexus/test/unit/impact-risk.test.ts` (new) | Issue table: File(25,13,0,0)→MEDIUM; Function(15,2,4,2)→HIGH; shared-axes File MEDIUM vs Function LOW; empty upstream UNKNOWN; downstream empty LOW; skipEnrichment unused axes; CRITICAL via direct≥30 still works with unused process axes |
| `gitnexus/test/integration/impact-file-risk-scale.test.ts` (new) | `withTestLbugDB` seed: `File:src/crypto.ts` ← 13 File IMPORTS, no File STEP_IN_PROCESS; `getEncryptionKey` with 2 CALLS from functions that have STEP_IN_PROCESS to 4 distinct Process nodes — reproduce inversion; assert new fields |
| `gitnexus/test/integration/impact-zero-caller-risk.test.ts` | Unchanged UNKNOWN/`riskNote`; candidates may grow `riskScale` — assert still present only when UNKNOWN for `riskNote` |
| `gitnexus/test/unit/impact-pagination.test.ts` | Hub CRITICAL unchanged |
| `gitnexus/test/unit/eval-formatters.test.ts` | Resolved result prints Risk + shared-axes line for File-shaped `riskScale` |
| Web | Only if an existing Graph RAG impact test snapshots `RISK:` |
Commands (exist in `gitnexus/package.json`): `npm run test:unit`, `npm test` (full vitest), `npx tsc --noEmit`. Web: `npm test`, `npx tsc -b --noEmit`. Integration needs `pretest:integration` / `npm run test:integration` (runs `scripts/build.js`).
## 9. Risk and Impact Analysis
Direct dependents of `_runImpactBFS` `[graph]`: `_impactImpl`, `impactByUid`. `_impactImpl` is the only d=1 of `impact` besides the methods own class. Any JSON consumer of `impact` (MCP, CLI `output(result)`, group local leg) sees additive fields — compatible if they ignore unknowns.
- **HIGH workflow:** Function HIGH/CRITICAL unchanged. File still cannot reach HIGH via processes; a File with `direct≥15` or `total≥100` still can. Agents that compare File MEDIUM vs Function HIGH must start using `riskSharedAxes` or `riskScale`.
- **Ambiguous `maxRisk`:** probes skip enrichment, so File vs Function candidates are already 2-axis there — inversion is weaker on that path.
- **Group `mergeRisk`:** still compares incomparable File local `risk` to crossing count. Do not retune this PR; if a group File target is common, follow-up.
- **Web:** browser bundle picks up `gitnexus-shared` export — confirm `gitnexus-shared` build/exports include the new file.
- **Performance:** none (pure arithmetic after existing enrichment).
- **Ladybug empty labels:** File detection must not rely on `symType` alone.
## 10. Files Expected to Change
| File | Symbols | Reason |
|---|---|---|
| `gitnexus-shared/src/impact-risk.ts` | `scoreImpactRisk` | New shared scorer |
| `gitnexus-shared/src/index.ts` | exports | Public helper |
| `gitnexus/src/mcp/local/local-backend.ts` | `_runImpactBFS`, ambiguous candidate map | Wire scorer + File unused axes |
| `gitnexus/src/mcp/tools.ts` | `impact` description | Contract |
| `gitnexus/src/cli/ai-context.ts` | generated Always Do | Agent warning |
| `gitnexus/src/cli/eval-server.ts` | `formatImpactResult` | Print scale |
| `gitnexus-web/src/core/llm/tools.ts` | web `impact` | Same formula |
| `gitnexus/test/unit/impact-risk.test.ts` | — | Table tests |
| `gitnexus/test/integration/impact-file-risk-scale.test.ts` | — | Seeded inversion |
| `gitnexus/test/unit/eval-formatters.test.ts` | `formatImpactResult` | Formatter |
## 11. Reusable Implementation Context
```yaml
implementation_context:
task_summary: "Fix #3075: File impact.risk is a 2-axis score silently labelled on a 4-axis scale. Extract scoreImpactRisk; mark File/skipEnrichment axes unused; add riskScale + riskSharedAxes; do not DEFINES-bridge or retune Function thresholds."
acceptance_criteria:
- "File vs Function comparison is either labelled incomparable (riskScale) or done via riskSharedAxes"
- "Function/Method risk for identical four-axis inputs unchanged"
- "riskNote still UNKNOWN-only"
- "Integration seed reproduces crypto.ts-style inversion and asserts the new fields"
primary_symbols:
- symbol: "_runImpactBFS"
file: "gitnexus/src/mcp/local/local-backend.ts"
lines: "6991-7888"
role: "BFS + enrichment + inline risk ladder (replace ladder only)"
- symbol: "scoreImpactRisk"
file: "gitnexus-shared/src/impact-risk.ts"
lines: "new"
role: "Pure scorer + shared-axes + riskScale"
- symbol: "formatImpactResult"
file: "gitnexus/src/cli/eval-server.ts"
lines: "305-641"
role: "Human/LLM text surface for impact JSON"
related_symbols:
- symbol: "_impactImpl"
relationship: "CALLS"
relevance: "Resolves target, PDG vs callgraph, ambiguous skipEnrichment probes"
- symbol: "impactByUid"
relationship: "CALLS"
relevance: "Group fan-out; keep skipPerSymbolEnrichment; still run aggregation"
- symbol: "mergeRisk"
relationship: "consumes risk string"
relevance: "Do not change this PR"
- symbol: "isCommunitySymbol"
relationship: "index gate"
relevance: "Why File modules_affected is always 0"
- symbol: "composeUnifiedPdgImpactResult"
relationship: "separate path"
relevance: "PDG risk stays UNKNOWN"
execution_path:
- "impact / callTool → _impactImpl (resolve symbol, File id prefix File:)"
- "_runImpactBFS: IMPORTS-heavy walk for File; CALLS walk for Function"
- "Enrich STEP_IN_PROCESS / MEMBER_OF on impacted ids (empty for File ids)"
- "scoreImpactRisk with unusedAxes for File or skipEnrichment"
- "JSON to MCP/CLI; formatImpactResult for eval text; web LLM tools parallel path"
pdg_constraints:
- description: "No PDG layer on the planning index; scorer is post-enrichment arithmetic"
affected_statements: []
implementation_consequence: "Do not wait on PDG; do not change pdg impact risk"
architectural_patterns:
- pattern: "Additive optional JSON fields on impact (riskNote, epistemic, partial)"
example_location: "gitnexus/src/mcp/local/local-backend.ts _runImpactBFS base object ~7754"
usage_guidance: "Add riskScale/riskSharedAxes the same way; never overload riskNote"
- pattern: "withTestLbugDB CREATE seed for impact contract"
example_location: "gitnexus/test/integration/impact-zero-caller-risk.test.ts"
usage_guidance: "Seed File IMPORTS + Function CALLS + Process membership separately"
files_to_modify:
- file: "gitnexus-shared/src/impact-risk.ts"
symbols: ["scoreImpactRisk"]
intended_change: "new pure scorer"
- file: "gitnexus-shared/src/index.ts"
symbols: []
intended_change: "re-export"
- file: "gitnexus/src/mcp/local/local-backend.ts"
symbols: ["_runImpactBFS"]
intended_change: "unusedAxes + helper; File type display"
- file: "gitnexus/src/mcp/tools.ts"
symbols: []
intended_change: "document fields"
- file: "gitnexus/src/cli/ai-context.ts"
symbols: []
intended_change: "agent comparability note"
- file: "gitnexus/src/cli/eval-server.ts"
symbols: ["formatImpactResult"]
intended_change: "print risk + shared-axes when incomparable"
- file: "gitnexus-web/src/core/llm/tools.ts"
symbols: []
intended_change: "import helper; extra prose line"
tests:
- file: "gitnexus/test/unit/impact-risk.test.ts"
scenarios:
- "File(25,13,0,0)+unused process/module → risk MEDIUM, comparableAcrossKinds false, riskSharedAxes MEDIUM"
- "Function(15,2,4,2) → HIGH, riskSharedAxes LOW (direct 2, total 15)"
- "upstream impactedCount 0 → UNKNOWN both fields"
- "direct 400 → CRITICAL even with unused process axes"
- file: "gitnexus/test/integration/impact-file-risk-scale.test.ts"
scenarios:
- "Seed File crypto.ts with 13 File importers vs getEncryptionKey with process-rich callers → inversion on risk, File incomparable, Function comparable"
- file: "gitnexus/test/unit/eval-formatters.test.ts"
scenarios:
- "formatImpactResult includes Shared-axes risk when riskScale.comparableAcrossKinds is false"
verification_commands:
- "cd gitnexus && npx tsc --noEmit"
- "cd gitnexus && npm run test:unit -- test/unit/impact-risk.test.ts test/unit/eval-formatters.test.ts test/unit/impact-pagination.test.ts"
- "cd gitnexus && npm run test:integration -- test/integration/impact-file-risk-scale.test.ts test/integration/impact-zero-caller-risk.test.ts"
- "cd gitnexus-web && npx tsc -b --noEmit"
risks:
- "Consumers that only read risk still see the inversion unless they adopt riskScale/riskSharedAxes — that is the chosen (explicit-scale) fix"
- "File type often empty; must key unusedAxes off File: id prefix"
- "gitnexus-shared export must reach the web bundle"
assumptions:
- "WHAT: File nodes never gain STEP_IN_PROCESS/MEMBER_OF without an indexer change. HOW: keep isCommunitySymbol and process traces as-is; tests seed File with zero such edges"
- "WHAT: Additive JSON fields are backward compatible. HOW: existing tests that exact-match the full impact object may need to allow extra keys — grep expect(res).toEqual on impact results before landing"
- "WHAT: HEAD 6bff33d is the pin; scorer line numbers ~7720. HOW: re-read the ladder if that hunk moved"
open_questions:
- "Whether GroupImpactResult should copy riskScale from local File targets (deferred unless tests already snapshot the full group object)"
avoid:
- "Do not DEFINES-bridge File→symbol processes/modules"
- "Do not lower Function process/module HIGH/CRITICAL thresholds"
- "Do not reuse riskNote for File incomparability"
- "Do not change PDG impact risk or detectChanges risk_level"
- "Do not treat labels(n)[0] or empty target.type as proof the node is not a File"
- "Do not repeat full repository discovery"
```
## 12. Assumptions and Open Questions
**Assumptions**
- Indexer will not start attaching File→Process/Community in this change (`isCommunitySymbol` stays). `[verified]` source; `[assumed]` future indexers.
- Ignoring unknown JSON keys is safe for MCP clients; any `toEqual` goldens in-repo must be updated. `[assumed]` — grep during implement.
- Stale-index inversion (`lbug-config.ts` vs `openLbugConnection`) is illustrative; the integration seed is the regression lock. `[graph]` vs `[verified]` seed.
**Open questions**
- Group `mergeRisk` + File local risk: copy `riskScale` onto `GroupImpactResult`? Default **no** unless a test breaks.
- Class/Interface STEP_IN_PROCESS sparsity: out of scope (#3075 is File).
- Printing `risk` on CLI formatted output is new (JSON already has it). Keep the extra lines short.
**Deferred**
- Recalibrated File-only HIGH thresholds.
- Indexing File community membership.
- DEFINES-bridge after a threshold RFC.
- Related #2975 (docs vs scorer wording) except as touched by `tools.ts`.
## 13. Definition of Done
- [ ] `scoreImpactRisk` is the only callgraph ladder in MCP and web.
- [ ] File (and skipEnrichment) results include `riskScale.comparableAcrossKinds === false` and `riskSharedAxes`.
- [ ] Function four-axis HIGH/CRITICAL cases in unit tests still pass with the same labels.
- [ ] Integration seed proves wider File blast + lower `risk` than a contained Function, and `riskSharedAxes` orders them without pretending processes existed on the File.
- [ ] `riskNote` still absent unless `risk === 'UNKNOWN'`.
- [ ] `tools.ts` + `ai-context.ts` state that File `risk` is not comparable to symbol `risk`.
- [ ] `cd gitnexus && npx tsc --noEmit` and the named unit/integration commands pass; web typecheck passes.

View file

@ -19,14 +19,14 @@
*
* False-positive suppression:
* - Skips calls whose receiver is a known non-tree-sitter library (`JSON`,
* `URL`, `marked`, `Number`).
* `URL`, `marked`, `Number`, `path`).
* - Skips calls whose first argument is a string-literal (grammar-load smoke
* tests like `_testParser.parse('service X { rpc Y (R) returns (R); }')`).
* - Skips test files (`.test.ts`/`.test.tsx`/`.spec.ts`).
* - Skips the `safe-parse.ts` helper itself.
*/
const SKIPPED_RECEIVERS = new Set(['JSON', 'URL', 'marked', 'Number', 'Math']);
const SKIPPED_RECEIVERS = new Set(['JSON', 'URL', 'marked', 'Number', 'Math', 'path']);
export default {
meta: {
@ -74,7 +74,7 @@ export default {
// Receiver-text-shape skip: anything matching well-known JS APIs that
// happen to have a `.parse(<expr>)` shape but aren't tree-sitter.
if (
/^(JSON|URL|marked|Number|Math|Date|globalThis\.JSON)\b/.test(receiverText) ||
/^(JSON|URL|marked|Number|Math|Date|path|globalThis\.JSON)\b/.test(receiverText) ||
/\bjson\.parse\b/i.test(receiverText)
) {
return;

View file

@ -28,8 +28,10 @@ from .proposer_sandbox import (
SandboxError,
)
PINNED_GITNEXUS_VERSION = "1.6.9"
HARNESS_ROOT = Path(__file__).resolve().parents[2]
# The mounted runtime is built from this checkout, so the pin tracks the harness'
# own package version. A hardcoded copy only drifts on release day (#3064).
PINNED_GITNEXUS_VERSION = json.loads((HARNESS_ROOT / "gitnexus" / "package.json").read_text())["version"]
CE_ARMS = frozenset({"ce_workflow", "ce_workflow_direct", "ce_review"})
SANDBOX_CE_PLUGIN = "/opt/compound-engineering-plugin"

View file

@ -1,7 +1,7 @@
{
"name": "gitnexus",
"description": "Code intelligence powered by a knowledge graph. Provides execution flow tracing, blast radius analysis, and augmented search across your codebase.",
"version": "1.6.9",
"version": "1.6.10",
"author": {
"name": "GitNexus"
},

View file

@ -1,7 +1,7 @@
{
"name": "gitnexus",
"description": "Code intelligence powered by a knowledge graph. Provides execution flow tracing, blast radius analysis, and augmented search across your codebase.",
"version": "1.6.9",
"version": "1.6.10",
"skills": "./skills",
"mcpServers": "./.mcp.json",
"hooks": "./hooks/hooks.json",

View file

@ -19,14 +19,21 @@ node .gitnexus/run.cjs analyze
Run from the project root. This parses all source files, builds the knowledge graph, writes it to `.gitnexus/`, and generates CLAUDE.md / AGENTS.md context files.
| Flag | Effect |
|------|--------|
| `--force` | Force full re-index even if up to date |
| Flag | Effect |
| -------------- | ---------------------------------------------------------------- |
| `--watch` | Keep a Git repository index current with serialized refreshes |
| `--debounce <ms>` | Watch quiet period before refresh (default: 300 ms) |
| `--force` | Force full re-index even if up to date |
| `--embeddings` | Enable embedding generation for semantic search (off by default) |
| `--drop-embeddings` | Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` preserves them. |
| `--pdg` | Build the program-dependence layers used by `explain` and `pdg_query` (taint, CDG, and REACHING_DEF). |
| `--spring-actuator <path>` | Import opt-in Spring Boot Actuator mappings, beans, conditions, configprops, and env snapshots. Forces a full rebuild; unsupported with `--watch`. |
**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale.
**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook detects staleness after `git commit` and `git merge` and notifies the agent to run `analyze` — the hook does not run analyze itself, to avoid blocking the agent for up to 120s and risking KuzuDB corruption on timeout.
For Spring runtime enrichment, pass a JSON bundle, one endpoint JSON file, or a directory containing endpoint files. Route evidence is authoritative only when `runtimeConfirmed === true`; `runtimeSource` records provenance and may also accompany `handler-conflict`. Env/configprops values are never persisted.
Use `node .gitnexus/run.cjs analyze --watch` for a long-lived local Git repository. It performs an initial analysis, queues scanner-admitted file changes, and retries intact failed batches with bounded backoff. Watch refreshes update only the graph: they skip AGENTS.md / CLAUDE.md injection and standard skill installation, so run a one-shot `analyze` when those generated files need updating. Watch rejects one-shot or context-output flags including `--force`, embedding flags, `--skills`, `--default-branch`, `--skip-agents-md`, `--skip-skills`, `--no-stats`, `--self-commit`, `--index-only`, and `--skip-git`. It never pulls remotes. Scheduled remote clone/pull is a different command: `gitnexus auto-sync`. Bare `gitnexus watch` is reserved and does not start either job. Running MCP and `serve` processes periodically check for a published replacement and reopen it without a restart. MCP checks are throttled to once every five seconds, so a tool call before the next check can briefly use the previous index.
### status — Check index freshness
@ -44,10 +51,10 @@ node .gitnexus/run.cjs clean
Deletes the `.gitnexus/` directory and unregisters the repo from the global registry. Use before re-indexing if the index is corrupt or after removing GitNexus from a project.
| Flag | Effect |
|------|--------|
| `--force` | Skip confirmation prompt |
| `--all` | Clean all indexed repos, not just the current one |
| Flag | Effect |
| --------- | ------------------------------------------------- |
| `--force` | Skip confirmation prompt |
| `--all` | Clean all indexed repos, not just the current one |
### wiki — Generate documentation from the graph
@ -55,19 +62,21 @@ Deletes the `.gitnexus/` directory and unregisters the repo from the global regi
node .gitnexus/run.cjs wiki
```
Generates repository documentation from the knowledge graph using an LLM. Requires an API key (saved to `~/.gitnexus/config.json` on first use).
Generates repository documentation from the knowledge graph using an LLM. HTTP providers require an API key (saved to `~/.gitnexus/config.json` on first use). Local CLI providers (`--provider cursor|claude|codex|opencode|grok`) use your existing CLI login.
| Flag | Effect |
|------|--------|
| `--force` | Force full regeneration, also required to re-gerenate an existing wiki in a different language |
| `--model <model>` | LLM model (default: MiniMax-M3) |
| `--base-url <url>` | LLM API base URL |
| `--api-key <key>` | LLM API key |
| `--concurrency <n>` | Parallel LLM calls (default: 3) |
| `--gist` | Publish wiki as a public GitHub Gist |
| Flag | Effect |
| ------------------- | ----------------------------------------- |
| `--force` | Force full regeneration, also required to re-generate an existing wiki in a different language |
| `--provider <name>` | LLM provider: minimax, openai, openrouter, azure, custom, cursor, claude, codex, opencode, or grok (default: minimax). Local CLIs (`cursor`, `claude`, `codex`, `opencode`, `grok`) use your existing CLI login and skip `--api-key`. |
| `--model <model>` | LLM model (default: MiniMax-M3) |
| `--base-url <url>` | LLM API base URL |
| `--api-key <key>` | LLM API key |
| `--concurrency <n>` | Parallel LLM calls (default: 3) |
| `--timeout <seconds>` | LLM request timeout in seconds (default: disabled) |
| `--retries <n>` | Max LLM retry attempts per request (default: 3) |
| `--lang <lang>` | Output language for generated documentation (e.g. english, chinese, spanish, japanese)|
| `--retries <n>` | Max LLM retry attempts per request (default: 3) |
| `--lang <lang>` | Output language for generated documentation (e.g. english, chinese, spanish, japanese) |
| `--gist` | Publish wiki as a public GitHub Gist |
### list — Show all indexed repos
```bash
@ -84,5 +93,5 @@ Lists all repositories registered in `~/.gitnexus/registry.json`. The MCP `list_
## Troubleshooting
- **"Not inside a git repository"**: Run from a directory inside a git repo
- **Index is stale after re-analyzing**: Restart Claude Code to reload the MCP server
- **Index is stale after re-analyzing**: Wait for the next MCP tool call to reopen the published index; this normally takes no more than five seconds
- **Embeddings slow**: Omit `--embeddings` (it's off by default) or set `OPENAI_API_KEY` for faster API-based embedding

View file

@ -2,7 +2,7 @@
"mcpServers": {
"gitnexus": {
"command": "npx",
"args": ["-y", "gitnexus@1.6.9", "mcp"]
"args": ["-y", "gitnexus@1.6.10", "mcp"]
}
}
}

View file

@ -2,7 +2,7 @@
"mcpServers": {
"gitnexus": {
"command": "npx",
"args": ["-y", "gitnexus@1.6.9", "mcp"]
"args": ["-y", "gitnexus@1.6.10", "mcp"]
}
}
}

View file

@ -2,7 +2,7 @@
"mcpServers": {
"gitnexus": {
"command": "npx",
"args": ["-y", "gitnexus@1.6.9", "mcp"]
"args": ["-y", "gitnexus@1.6.10", "mcp"]
}
}
}

View file

@ -2,7 +2,7 @@
"mcpServers": {
"gitnexus": {
"command": "npx",
"args": ["-y", "gitnexus@1.6.9", "mcp"]
"args": ["-y", "gitnexus@1.6.10", "mcp"]
}
}
}

View file

@ -93,6 +93,15 @@ dispatch, cross-language calls), so few-callers ⇒ LOW does **not** apply. The
result carries a `riskNote` saying so. Confirm with a text search before
treating the symbol as safe to change or delete.
`risk` is the edit gate: warn on HIGH/CRITICAL and stop on UNKNOWN until the
uncertainty is resolved. Within single-repo mode, compare File and symbol
targets with local `riskSharedAxes` (direct/total only). Within group mode,
compare only group results: their `riskSharedAxes` overlays resolved
cross-repo crossings on that local value. Never use either field to waive the
edit gate. Check `riskScale.unusedAxes` before comparing kinds: MCP File walks
omit process/module axes, while web Graph-RAG expands File targets to in-file
symbols before enrichment.
## Tools
**impact** — the primary tool for symbol blast radius. If MCP is unavailable, use `node .gitnexus/run.cjs impact <symbol> --direction upstream --repo .` instead:

View file

@ -2,7 +2,7 @@
"mcpServers": {
"gitnexus": {
"command": "npx",
"args": ["-y", "gitnexus@1.6.9", "mcp"]
"args": ["-y", "gitnexus@1.6.10", "mcp"]
}
}
}

View file

@ -2,7 +2,7 @@
"mcpServers": {
"gitnexus": {
"command": "npx",
"args": ["-y", "gitnexus@1.6.9", "mcp"]
"args": ["-y", "gitnexus@1.6.10", "mcp"]
}
}
}

View file

@ -2,7 +2,7 @@
"mcpServers": {
"gitnexus": {
"command": "npx",
"args": ["-y", "gitnexus@1.6.9", "mcp"]
"args": ["-y", "gitnexus@1.6.10", "mcp"]
}
}
}

View file

@ -2,7 +2,7 @@
"mcpServers": {
"gitnexus": {
"command": "npx",
"args": ["-y", "gitnexus@1.6.9", "mcp"]
"args": ["-y", "gitnexus@1.6.10", "mcp"]
}
}
}

View file

@ -2,7 +2,7 @@
"mcpServers": {
"gitnexus": {
"command": "npx",
"args": ["-y", "gitnexus@1.6.9", "mcp"]
"args": ["-y", "gitnexus@1.6.10", "mcp"]
}
}
}

View file

@ -2,7 +2,7 @@
"mcpServers": {
"gitnexus": {
"command": "npx",
"args": ["-y", "gitnexus@1.6.9", "mcp"]
"args": ["-y", "gitnexus@1.6.10", "mcp"]
}
}
}

View file

@ -85,40 +85,241 @@ function findGitNexusDir(startDir) {
return null;
}
function tokenizeShellWords(command) {
const tokens = [];
let current = '';
let quote = null;
let escaped = false;
let hasToken = false;
for (let index = 0; index < command.length; index += 1) {
const char = command[index];
if (escaped) {
current += char;
escaped = false;
hasToken = true;
continue;
}
if (quote === "'") {
if (char === "'") quote = null;
else current += char;
hasToken = true;
continue;
}
if (quote === '"') {
if (char === '"') {
quote = null;
} else if (char === '\\') {
const next = command[index + 1];
if (next === '$' || next === '`' || next === '"' || next === '\\') {
escaped = true;
} else {
current += '\\';
}
} else {
current += char;
}
hasToken = true;
continue;
}
if (char === '\\') {
const next = command[index + 1];
if (next === undefined || /\s/.test(next) || next === "'" || next === '"' || next === '\\') {
escaped = true;
} else {
current += '\\' + next;
index += 1;
}
hasToken = true;
} else if (char === "'" || char === '"') {
quote = char;
hasToken = true;
} else if (/\s/.test(char)) {
if (hasToken) tokens.push(current);
current = '';
hasToken = false;
} else if (char === ';' || char === '|' || char === '&') {
if (hasToken) tokens.push(current);
current = '';
hasToken = false;
const next = command[index + 1];
if ((char === '|' || char === '&') && next === char) {
tokens.push(char + char);
index += 1;
} else {
tokens.push(char);
}
} else {
current += char;
hasToken = true;
}
}
if (escaped) current += '\\';
if (hasToken) tokens.push(current);
return tokens;
}
function parseRgGrepPattern(cmd) {
const tokens = cmd.split(/\s+/);
const tokens = tokenizeShellWords(cmd);
let foundCmd = false;
let skipNext = false;
let skipNextAsPattern = false;
let endOfOptions = false;
let explicitPatternSeen = false;
let patternFileSeen = false;
const flagsWithValues = new Set([
'-e',
'-f',
'--file',
'-m',
'--max-count',
'-A',
'-B',
'-C',
'-g',
'--glob',
'--iglob',
'-t',
'--type',
'--include',
'--exclude',
'--encoding',
'--path',
]);
const rgValueFlags = new Set(['-r', '--replace']);
const patternFlags = new Set(['-e', '--regexp']);
const connectors = new Set(['&&', '||', ';', '|', '&']);
const wrappers = new Set([
'npx',
'bunx',
'pnpm',
'yarn',
'npm',
'sudo',
'env',
'command',
'time',
'nice',
'xargs',
'dlx',
'exec',
'run',
'git',
]);
const wrapperFlagsWithValues = new Set([
'--package',
'-p',
'--call',
'--prefix',
'--shell',
'--filter',
'--workspace',
'--dir',
'--cwd',
]);
const basename = (token) =>
token
.split(/[\\/]/)
.pop()
?.replace(/\.(exe|cmd|bat)$/i, '');
let previousToken;
let seenWrapper = false;
let searchCommand = null;
for (const token of tokens) {
if (skipNext) {
skipNext = false;
if (skipNextAsPattern) {
skipNextAsPattern = false;
if (token.length >= 3) return token;
}
previousToken = token;
continue;
}
if (!foundCmd) {
if (/\brg$|\bgrep$/.test(token)) foundCmd = true;
if (connectors.has(token)) {
seenWrapper = false;
previousToken = token;
continue;
}
const commandName = basename(token);
if (wrappers.has(commandName)) {
seenWrapper = true;
previousToken = token;
continue;
}
if (seenWrapper && token.startsWith('-')) {
const flagName = token.split('=', 1)[0];
if (!token.includes('=') && wrapperFlagsWithValues.has(flagName)) skipNext = true;
previousToken = token;
continue;
}
if (seenWrapper && /^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) {
previousToken = token;
continue;
}
const atCommandPosition =
previousToken === undefined ||
connectors.has(previousToken) ||
wrappers.has(basename(previousToken)) ||
seenWrapper;
if (atCommandPosition && (commandName === 'rg' || commandName === 'grep')) {
foundCmd = true;
searchCommand = commandName;
} else if (seenWrapper) {
seenWrapper = false;
}
previousToken = token;
continue;
}
previousToken = token;
if (endOfOptions) {
if (explicitPatternSeen || patternFileSeen) continue;
return token.length >= 3 ? token : null;
}
if (token === '--') {
endOfOptions = true;
continue;
}
if (token.startsWith('-')) {
if (flagsWithValues.has(token)) skipNext = true;
if (token === '-f' || token === '--file') {
skipNext = true;
patternFileSeen = true;
continue;
}
if (token.startsWith('--file=')) {
patternFileSeen = true;
continue;
}
if (token.startsWith('--regexp=')) {
explicitPatternSeen = true;
const value = token.slice('--regexp='.length);
if (value.length >= 3) return value;
continue;
}
const attachedPattern = token.match(/^-e(.+)$/);
if (attachedPattern) {
explicitPatternSeen = true;
if (attachedPattern[1].length >= 3) return attachedPattern[1];
continue;
}
if (
flagsWithValues.has(token) ||
patternFlags.has(token) ||
(searchCommand === 'rg' && rgValueFlags.has(token))
) {
skipNext = true;
skipNextAsPattern = patternFlags.has(token);
if (skipNextAsPattern) explicitPatternSeen = true;
}
continue;
}
const cleaned = token.replace(/['"]/g, '');
return cleaned.length >= 3 ? cleaned : null;
if (explicitPatternSeen || patternFileSeen) continue;
return token.length >= 3 ? token : null;
}
return null;
}
@ -179,12 +380,6 @@ function extractPattern(toolName, toolInput) {
if (t === 'shell') {
const cmd = toolInput.command || '';
if (!/\brg\b|\bgrep\b/.test(cmd)) return null;
// NOTE: parseRgGrepPattern uses split(/\s+/) and cannot handle shell
// quoting. `rg "User Service" src/` returns "User" (the first token
// after the rg/grep arg, with surrounding quotes stripped) — the
// multi-word pattern is intentionally not reconstructed since BM25 is
// already token-tolerant. Quoted single tokens (`rg "validateUser"`)
// work fine.
return parseRgGrepPattern(cmd);
}
@ -282,4 +477,6 @@ function main() {
}
}
main();
if (require.main === module) main();
module.exports = { parseRgGrepPattern, tokenizeShellWords };

View file

@ -93,6 +93,15 @@ dispatch, cross-language calls), so few-callers ⇒ LOW does **not** apply. The
result carries a `riskNote` saying so. Confirm with a text search before
treating the symbol as safe to change or delete.
`risk` is the edit gate: warn on HIGH/CRITICAL and stop on UNKNOWN until the
uncertainty is resolved. Within single-repo mode, compare File and symbol
targets with local `riskSharedAxes` (direct/total only). Within group mode,
compare only group results: their `riskSharedAxes` overlays resolved
cross-repo crossings on that local value. Never use either field to waive the
edit gate. Check `riskScale.unusedAxes` before comparing kinds: MCP File walks
omit process/module axes, while web Graph-RAG expands File targets to in-file
symbols before enrichment.
## Tools
**impact** — the primary tool for symbol blast radius. If MCP is unavailable, use `node .gitnexus/run.cjs impact <symbol> --direction upstream --repo .` instead:

View file

@ -45,6 +45,21 @@ export type NodeLabel =
| 'Section'
| 'Route'
| 'Tool'
/**
* A message-broker destination a Kafka topic, a Rabbit exchange/routing
* key, a JMS queue, a Spring Cloud Stream binding. The framework overlay for
* ASYNCHRONOUS entry/exit points, symmetric to `Route` for HTTP.
*
* Identity is `(broker, resolved ADDRESS)`, so a publisher and a consumer of
* the same address on the same broker land on one node and the connection is
* a single hop while a Kafka topic and a Rabbit queue that share a name
* stay two nodes, the same way `GET /x` and `POST /x` are two Routes. A
* destination whose address could NOT be resolved is keyed by its source
* location instead and carries no `address` property at all. See
* `pipeline-phases/spring-destinations.ts` for why an unresolved spelling may
* not key a node, and `ingestion/destination-key.ts` for why the broker may.
*/
| 'Destination'
// Taint/PDG substrate (issue #2080). Intra-procedural control-flow node.
// Emitted by no phase yet — M1 (#2081) populates these behind an opt-in.
| 'BasicBlock';
@ -95,6 +110,30 @@ export type NodeProperties = {
responseKeys?: string[];
errorKeys?: string[];
middleware?: string[];
/** Route runtime evidence is authoritative only when this is exactly true. */
runtimeConfirmed?: boolean;
/** Provenance of runtime evidence; presence alone does not imply confirmation. */
runtimeSource?: string;
/** Runtime result such as runtime-confirmed or handler-conflict. */
runtimeStatus?: string;
// Destination (async messaging overlay). See the `Destination` label above.
/** The RESOLVED broker address. Together with `broker` it is the key a
* cross-repository pass joins on. Present only when the address resolved:
* absent is the load-bearing state, because an absent property cannot match
* another absent property. */
address?: string;
/** Broker family the syntax attests to (`kafka`, `rabbit`, `jms`, ). Part
* of the node's identity alongside `address`, not a label on it. */
broker?: string;
/** How the address was arrived at (`literal`, `constant`) when it resolved,
* or the named reason it did not. */
resolution?: string;
/** Configuration key named by an unresolvable `${}` placeholder. The key
* only configuration VALUES are deliberately absent from this graph. */
configKey?: string;
/** The `${key:default}` default text. Not an address: configuration can
* override it and the graph cannot see whether it did. */
configDefault?: string;
// BasicBlock (taint/PDG substrate, issue #2080) — reuses filePath/startLine/endLine.
text?: string;
/** BasicBlock: space-joined leaf callee names invoked in the block the
@ -122,6 +161,19 @@ export type RelationshipType =
| 'MEMBER_OF'
| 'STEP_IN_PROCESS'
| 'HANDLES_ROUTE'
/** Outbound async messaging. Source = the callable that performs the publish
* (or its File); target = the `Destination` it publishes to. Emitted by
* `pipeline-phases/spring-destinations.ts` from Spring messaging-template
* calls (`kafkaTemplate.send(...)`, `rabbitTemplate.convertAndSend(...)`).
* One edge per address: a publish that names two destinations yields two
* edges, and `reason` records which argument each came from. */
| 'PUBLISHES_TO'
/** Inbound async messaging the mirror of `PUBLISHES_TO`. Source = the
* annotated handler callable (or its File); target = the `Destination` it
* subscribes to. Emitted from `@KafkaListener` / `@RabbitListener` /
* `@JmsListener` and their siblings. Together the two types make
* "who else reads what this service writes" a two-hop traversal. */
| 'CONSUMES_FROM'
| 'FETCHES'
| 'HANDLES_TOOL'
| 'ENTRY_POINT_OF'

View file

@ -0,0 +1,151 @@
export type ImpactRisk = 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL' | 'UNKNOWN';
export type ImpactRiskAxis = 'processes' | 'modules';
export type UnusedImpactRiskReason =
| 'file-nodes-have-no-process-or-community-membership'
| 'enrichment-skipped'
| 'enrichment-budget-exhausted'
| 'enrichment-truncated'
| 'enrichment-query-failed';
export interface UnusedImpactRiskAxis {
axis: ImpactRiskAxis;
reason: UnusedImpactRiskReason;
}
export interface ImpactRiskInput {
direction: 'upstream' | 'downstream';
directCount: number;
processCount: number;
moduleCount: number;
impactedCount: number;
unusedAxes?: readonly UnusedImpactRiskAxis[];
}
export interface ImpactRiskResult {
risk: ImpactRisk;
riskSharedAxes: ImpactRisk;
riskScale: {
comparableAcrossKinds: boolean;
unusedAxes: readonly UnusedImpactRiskAxis[];
};
}
function score(
input: Pick<
ImpactRiskInput,
'direction' | 'directCount' | 'processCount' | 'moduleCount' | 'impactedCount'
>,
): ImpactRisk {
const { direction, directCount, processCount, moduleCount, impactedCount } = input;
if (direction === 'upstream' && impactedCount === 0) return 'UNKNOWN';
if (directCount >= 30 || processCount >= 5 || moduleCount >= 5 || impactedCount >= 200) {
return 'CRITICAL';
}
if (directCount >= 15 || processCount >= 3 || moduleCount >= 3 || impactedCount >= 100) {
return 'HIGH';
}
if (directCount >= 5 || impactedCount >= 30) return 'MEDIUM';
return 'LOW';
}
const UNMEASURED_REASONS: ReadonlySet<UnusedImpactRiskReason> = new Set([
'file-nodes-have-no-process-or-community-membership',
'enrichment-skipped',
'enrichment-budget-exhausted',
]);
function unusedPair(reason: UnusedImpactRiskReason): UnusedImpactRiskAxis[] {
return [
{ axis: 'processes', reason },
{ axis: 'modules', reason },
];
}
function countsWithUnmeasuredAxesZeroed(
input: ImpactRiskInput,
): Pick<
ImpactRiskInput,
'direction' | 'directCount' | 'processCount' | 'moduleCount' | 'impactedCount'
> {
let processCount = input.processCount;
let moduleCount = input.moduleCount;
for (const unused of input.unusedAxes ?? []) {
if (!UNMEASURED_REASONS.has(unused.reason)) continue;
if (unused.axis === 'processes') processCount = 0;
if (unused.axis === 'modules') moduleCount = 0;
}
return {
direction: input.direction,
directCount: input.directCount,
processCount,
moduleCount,
impactedCount: input.impactedCount,
};
}
/** Map walk outcomes to unused process/module axes so comparability matches what was sampled. */
export function unusedAxesForImpactWalk(input: {
isFileTarget: boolean;
skipEnrichment: boolean;
maxChunks: number;
processQueryFailed: boolean;
moduleQueryFailed: boolean;
/** When 0, a zero chunk budget is not an unused-axis event — there was nothing to enrich. */
impactedCount: number;
/** True when process/module queries ran on a strict subset of impacted symbols. */
enrichmentTruncated?: boolean;
}): UnusedImpactRiskAxis[] {
if (input.isFileTarget) {
return unusedPair('file-nodes-have-no-process-or-community-membership');
}
if (input.skipEnrichment) {
return unusedPair('enrichment-skipped');
}
if (input.maxChunks === 0 && input.impactedCount > 0) {
return unusedPair('enrichment-budget-exhausted');
}
const unused: UnusedImpactRiskAxis[] = [];
if (input.enrichmentTruncated) {
unused.push(...unusedPair('enrichment-truncated'));
}
if (input.processQueryFailed) {
unused.push({ axis: 'processes', reason: 'enrichment-query-failed' });
}
if (input.moduleQueryFailed) {
unused.push({ axis: 'modules', reason: 'enrichment-query-failed' });
}
return unused;
}
const INCOMPLETE_SAMPLE_REASONS: ReadonlySet<UnusedImpactRiskReason> = new Set([
'enrichment-query-failed',
'enrichment-truncated',
]);
export function scoreImpactRisk(input: ImpactRiskInput): ImpactRiskResult {
const unusedAxes = input.unusedAxes ?? [];
const observedRisk = score(countsWithUnmeasuredAxesZeroed(input));
const incompleteSample = unusedAxes.some((unused) =>
INCOMPLETE_SAMPLE_REASONS.has(unused.reason),
);
// Failed queries and truncated samples make observed process/module counts
// lower bounds. Preserve any HIGH/CRITICAL warning already proved by those
// counts, but never emit a confident LOW/MEDIUM edit gate from an incomplete
// enrichment pass.
const risk =
incompleteSample && (observedRisk === 'LOW' || observedRisk === 'MEDIUM')
? 'UNKNOWN'
: observedRisk;
return {
risk,
riskSharedAxes: score({ ...input, processCount: 0, moduleCount: 0 }),
riskScale: {
comparableAcrossKinds: unusedAxes.length === 0,
unusedAxes,
},
};
}

View file

@ -25,6 +25,17 @@ export {
} from './language-detection.js';
export type { MroStrategy } from './mro-strategy.js';
// Impact risk scoring
export { scoreImpactRisk, unusedAxesForImpactWalk } from './impact-risk.js';
export type {
ImpactRisk,
ImpactRiskAxis,
ImpactRiskInput,
ImpactRiskResult,
UnusedImpactRiskAxis,
UnusedImpactRiskReason,
} from './impact-risk.js';
// Pipeline progress
export type { PipelinePhase, PipelineProgress } from './pipeline.js';

View file

@ -40,6 +40,8 @@ export const NODE_TABLES = [
'Module',
'Route',
'Tool',
// Async messaging overlay — the broker-side counterpart of `Route`.
'Destination',
// Taint/PDG substrate (issue #2080) — inert until M1 (#2081) emits blocks.
'BasicBlock',
] as const;
@ -64,6 +66,8 @@ export const REL_TYPES = [
'MEMBER_OF',
'STEP_IN_PROCESS',
'HANDLES_ROUTE',
'PUBLISHES_TO',
'CONSUMES_FROM',
'FETCHES',
'HANDLES_TOOL',
'ENTRY_POINT_OF',

View file

@ -36,28 +36,28 @@
"pandemonium": "^2.4.0",
"react": "^19.2.5",
"react-dom": "^19.2.8",
"react-i18next": "^17.0.11",
"react-i18next": "^17.0.12",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^16.1.1",
"react-zoom-pan-pinch": "^4.0.3",
"remark-gfm": "^4.0.1",
"sigma": "^3.0.3",
"tailwindcss": "^4.3.3",
"uuid": "^14.0.1",
"uuid": "^14.0.2",
"zod": "^4.4.3"
},
"devDependencies": {
"@babel/types": "^8.0.4",
"@playwright/test": "^1.62.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/jest-dom": "^7.0.0",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@testing-library/user-event": "^14.6.6",
"@types/dompurify": "^3.2.0",
"@types/node": "^26.0.1",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.4",
"@types/react-syntax-highlighter": "^15.5.13",
"@vercel/node": "^5.9.9",
"@vercel/node": "^5.10.2",
"@vitejs/plugin-react": "^6.0.5",
"@vitest/coverage-v8": "^4.1.9",
"jsdom": "^29.1.1",
@ -246,9 +246,9 @@
}
},
"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==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@ -307,13 +307,6 @@
"specificity": "bin/cli.js"
}
},
"node_modules/@bytecodealliance/preview2-shim": {
"version": "0.17.6",
"resolved": "https://registry.npmjs.org/@bytecodealliance/preview2-shim/-/preview2-shim-0.17.6.tgz",
"integrity": "sha512-n3cM88gTen5980UOBAD6xDcNNL3ocTK8keab21bpx1ONdA+ARj7uD1qoFxOWCyKlkpSi195FH+GeAut7Oc6zZw==",
"dev": true,
"license": "(Apache-2.0 WITH LLVM-exception)"
},
"node_modules/@cfworker/json-schema": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz",
@ -1419,17 +1412,6 @@
"node": ">=20"
}
},
"node_modules/@renovatebot/pep440": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@renovatebot/pep440/-/pep440-4.2.1.tgz",
"integrity": "sha512-2FK1hF93Fuf1laSdfiEmJvSJPVIDHEUTz68D3Fi9s0IZrrpaEcj6pTFBTbYvsgC5du4ogrtf5re7yMMvrKNgkw==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^20.9.0 || ^22.11.0 || ^24",
"pnpm": "^10.0.0"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
@ -1517,9 +1499,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1536,9 +1515,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -1555,9 +1531,6 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1574,9 +1547,6 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1593,9 +1563,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1612,9 +1579,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -1865,9 +1829,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1884,9 +1845,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -1903,9 +1861,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1922,9 +1877,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -2091,9 +2043,9 @@
}
},
"node_modules/@testing-library/jest-dom": {
"version": "6.9.1",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
"integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.0.tgz",
"integrity": "sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -2105,9 +2057,12 @@
"redent": "^3.0.0"
},
"engines": {
"node": ">=14",
"node": ">=22",
"npm": ">=6",
"yarn": ">=1"
},
"peerDependencies": {
"@testing-library/dom": ">=10 <11"
}
},
"node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
@ -2146,9 +2101,9 @@
}
},
"node_modules/@testing-library/user-event": {
"version": "14.6.1",
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
"integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
"version": "14.6.6",
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.6.tgz",
"integrity": "sha512-Jbs9FpkkIDw8FgSc6kOVsOv8JuuqGAL7J4X1oot77JxAoDlkNn2GRkd0aYRVuQ+pVQAiHWVkE4rX/dkF5fBiCw==",
"dev": true,
"license": "MIT",
"engines": {
@ -2638,13 +2593,12 @@
}
},
"node_modules/@vercel/build-utils": {
"version": "14.0.5",
"resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-14.0.5.tgz",
"integrity": "sha512-ChbTraIvChbcFXMwDPLE8MoWpNGSRhJ2cXsE0V3iJQIVYDRgjFoT6JzWfkuc7w/3ojLLr8eMoae7M1v6OXoC5Q==",
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-14.2.0.tgz",
"integrity": "sha512-GwmtB31tBXQEzFw11grr8BKFCBdUORmYeooB0ZtonaCXZMZaPCHLBFTMFKsvaV6ZciQORPInRwXShbFvmnjqtg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@vercel/python-analysis": "0.13.2",
"cjs-module-lexer": "1.2.3",
"es-module-lexer": "1.5.0"
}
@ -2691,9 +2645,9 @@
}
},
"node_modules/@vercel/node": {
"version": "5.9.9",
"resolved": "https://registry.npmjs.org/@vercel/node/-/node-5.9.9.tgz",
"integrity": "sha512-jaMocJLa+rP3WpwYrbx2kUpHObjXK/JZOsbtmodDMAtfXbwl7niPNcEbdYYj/fBPSX8yRUXBF3tQsasocbjD5Q==",
"version": "5.10.2",
"resolved": "https://registry.npmjs.org/@vercel/node/-/node-5.10.2.tgz",
"integrity": "sha512-YBXcoQVOh5O2ySXvzE+POhPEQEPMJJo4ctlMMdp5why/NIoa8m6gotv14j8Uo6D5qyZsnc+0+++JgUiV4mYB6w==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@ -2701,7 +2655,7 @@
"@edge-runtime/primitives": "4.1.0",
"@edge-runtime/vm": "3.2.0",
"@types/node": "20.11.0",
"@vercel/build-utils": "14.0.5",
"@vercel/build-utils": "14.2.0",
"@vercel/error-utils": "2.2.1",
"@vercel/nft": "1.10.0",
"@vercel/static-config": "3.4.1",
@ -2738,32 +2692,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@vercel/python-analysis": {
"version": "0.13.2",
"resolved": "https://registry.npmjs.org/@vercel/python-analysis/-/python-analysis-0.13.2.tgz",
"integrity": "sha512-IEr5K2gvX143NBoQc1W4BWrdDWjZwxnIT6UrL5Y1dnyH7Cqc4AV00FIAddB1YpnIZBJwT4ZhE8QbgqBeO6C9Zw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@bytecodealliance/preview2-shim": "0.17.6",
"@renovatebot/pep440": "4.2.1",
"fs-extra": "11.1.1",
"js-yaml": "4.1.1",
"minimatch": "10.1.1",
"smol-toml": "1.5.2",
"zod": "3.22.4"
}
},
"node_modules/@vercel/python-analysis/node_modules/zod": {
"version": "3.22.4",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz",
"integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/@vercel/static-config": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/@vercel/static-config/-/static-config-3.4.1.tgz",
@ -3051,13 +2979,6 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0"
},
"node_modules/aria-query": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
@ -4511,21 +4432,6 @@
"node": ">=0.4.x"
}
},
"node_modules/fs-extra": {
"version": "11.1.1",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.1.1.tgz",
"integrity": "sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=14.14"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@ -5197,19 +5103,6 @@
"license": "MIT",
"peer": true
},
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/jsdom": {
"version": "29.1.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
@ -5265,9 +5158,9 @@
}
},
"node_modules/jsdom/node_modules/undici": {
"version": "7.25.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
"integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
"dev": true,
"license": "MIT",
"engines": {
@ -5319,19 +5212,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/jsonfile": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
"integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/katex": {
"version": "0.16.47",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz",
@ -6884,9 +6764,9 @@
}
},
"node_modules/nanoid": {
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"funding": [
{
"type": "github",
@ -7414,12 +7294,12 @@
}
},
"node_modules/react-i18next": {
"version": "17.0.11",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.11.tgz",
"integrity": "sha512-cDtkXgxjuFTWUH6V+aQn1Ve5vDiUztCNPWW5GtSHDccsgRXO1nE6QFWCEmc1KAutrb3OUv87wFShJL5RhUwPXg==",
"version": "17.0.12",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.12.tgz",
"integrity": "sha512-lFWPEGkxQ6RhusdUkysFBD58VHfSSzvHBzqMgN0SvfVpdQGfwtNkStTqdy08/sJd7s807qqutgx93fRpD0DJ3Q==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2",
"@babel/runtime": "^7.29.7",
"html-parse-stringify": "^4.0.1",
"use-sync-external-store": "^1.6.0"
},
@ -7805,19 +7685,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/smol-toml": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz",
"integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">= 18"
},
"funding": {
"url": "https://github.com/sponsors/cyyynthia"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -7952,9 +7819,9 @@
}
},
"node_modules/tar": {
"version": "7.5.20",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.20.tgz",
"integrity": "sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==",
"version": "7.5.22",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz",
"integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
@ -8190,9 +8057,9 @@
"license": "MIT"
},
"node_modules/undici": {
"version": "6.24.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.24.0.tgz",
"integrity": "sha512-lVLNosgqo5EkGqh5XUDhGfsMSoO8K0BAN0TyJLvwNRSl4xWGZlCVYsAIpa/OpA3TvmnM01GWcoKmc3ZWo5wKKA==",
"version": "6.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz",
"integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==",
"dev": true,
"license": "MIT",
"engines": {
@ -8293,16 +8160,6 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
@ -8313,9 +8170,9 @@
}
},
"node_modules/uuid": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz",
"integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==",
"version": "14.0.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.2.tgz",
"integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"

View file

@ -46,28 +46,28 @@
"pandemonium": "^2.4.0",
"react": "^19.2.5",
"react-dom": "^19.2.8",
"react-i18next": "^17.0.11",
"react-i18next": "^17.0.12",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^16.1.1",
"react-zoom-pan-pinch": "^4.0.3",
"remark-gfm": "^4.0.1",
"sigma": "^3.0.3",
"tailwindcss": "^4.3.3",
"uuid": "^14.0.1",
"uuid": "^14.0.2",
"zod": "^4.4.3"
},
"devDependencies": {
"@babel/types": "^8.0.4",
"@playwright/test": "^1.62.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/jest-dom": "^7.0.0",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@testing-library/user-event": "^14.6.6",
"@types/dompurify": "^3.2.0",
"@types/node": "^26.0.1",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.4",
"@types/react-syntax-highlighter": "^15.5.13",
"@vercel/node": "^5.9.9",
"@vercel/node": "^5.10.2",
"@vitejs/plugin-react": "^6.0.5",
"@vitest/coverage-v8": "^4.1.9",
"jsdom": "^29.1.1",
@ -83,7 +83,7 @@
},
"@vercel/node": {
"path-to-regexp": "6.3.0",
"undici": "6.24.0"
"undici": "6.28.0"
},
"@vercel/python-analysis": {
"minimatch": "10.2.3",

View file

@ -13,8 +13,12 @@
import { tool } from '@langchain/core/tools';
import { z } from 'zod';
import { NODE_TABLES, REL_TYPES } from 'gitnexus-shared';
import type { EnrichedSearchResult, GrepResult } from '../../services/backend-client';
import { NODE_TABLES, REL_TYPES, scoreImpactRisk, unusedAxesForImpactWalk } from 'gitnexus-shared';
import type {
EnrichedSearchResult,
GrepOptions,
GrepResponse,
} from '../../services/backend-client';
/**
* Tool names registered by createGraphRAGTools kept in sync with each tool's `name`
@ -44,7 +48,7 @@ export interface GraphRAGBackend {
query: string,
opts?: { limit?: number; mode?: 'hybrid' | 'semantic' | 'bm25'; enrich?: boolean },
) => Promise<EnrichedSearchResult[]>;
grep: (pattern: string, limit?: number) => Promise<GrepResult[]>;
grep: (pattern: string, limit?: number, opts?: GrepOptions) => Promise<GrepResponse>;
readFile: (filePath: string) => Promise<string>;
}
@ -375,20 +379,22 @@ MATCH (n:Function {id: emb.nodeId}) RETURN n`,
}
const limit = maxResults ?? 100;
const fullPattern = fileFilter
? `(?=.*${fileFilter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}).*${pattern}`
: pattern;
const results = await backendGrep(fullPattern, limit);
const { results, timedOut } = await backendGrep(pattern, limit, {
fileFilter,
caseSensitive,
});
const timeoutMsg = timedOut
? '\n\n(Scan timed out after a few seconds — results may be incomplete)'
: '';
if (results.length === 0) {
return `No matches for "${pattern}"${fileFilter ? ` in files matching "${fileFilter}"` : ''}`;
return `No matches for "${pattern}"${fileFilter ? ` in files matching "${fileFilter}"` : ''}${timeoutMsg}`;
}
const formatted = results.map((r) => `${r.filePath}:${r.line}: ${r.text}`).join('\n');
const truncatedMsg = results.length >= limit ? `\n\n(Showing first ${limit} results)` : '';
return `Found ${results.length} matches:\n\n${formatted}${truncatedMsg}`;
return `Found ${results.length} matches:\n\n${formatted}${truncatedMsg}${timeoutMsg}`;
} catch (error) {
return `Grep error: ${error instanceof Error ? error.message : String(error)}`;
}
@ -396,16 +402,20 @@ MATCH (n:Function {id: emb.nodeId}) RETURN n`,
{
name: 'grep',
description:
'Search for exact text patterns across all files using regex. Use for finding specific strings, error messages, TODOs, variable names, etc.',
'Search file contents with a regular expression (server executes it as a real regex — alternation like "sign|Sign" works). Matches are case-insensitive unless caseSensitive is set. fileFilter keeps only files whose path contains the substring. Each call caps at maxResults matches (default 100) and the server stops after a few seconds (the tool will say so if the scan was incomplete), so prefer precise patterns over catch-alls.',
schema: z.object({
pattern: z
.string()
.describe('Regex pattern to search for (e.g., "TODO", "console\\.log", "API_KEY")'),
.describe(
'Regex pattern to search for (e.g., "TODO|FIXME", "console\\.log", "signOrder")',
),
fileFilter: z
.string()
.optional()
.nullable()
.describe('Only search files containing this string (e.g., ".ts", "src/api")'),
.describe(
'Only search files whose path contains this substring (e.g., ".ts", "src/api", "Controller.java")',
),
caseSensitive: z
.boolean()
.optional()
@ -1219,7 +1229,7 @@ MATCH (n:Function {id: emb.nodeId}) RETURN n`,
const targetFileName = (targetFilePath || target).split('/').pop() || target;
const baseName = targetFileName.replace(/\.[^/.]+$/, '');
try {
const hints = await backendGrep(`\\b${escapeRegex(baseName)}\\b`, 15);
const { results: hints } = await backendGrep(`\\b${escapeRegex(baseName)}\\b`, 15);
const filtered = hints.filter((h) => h.filePath !== targetFilePath);
if (filtered.length > 0) {
@ -1275,6 +1285,9 @@ MATCH (n:Function {id: emb.nodeId}) RETURN n`,
stepCount: number | null;
}> = [];
let affectedClusters: Array<{ label: string; hits: number; impact: string }> = [];
let processQueryFailed = false;
let clusterQueryFailed = false;
let clusterClassificationFailed = false;
if (trimmedIds.length > 0) {
const processQuery = `
@ -1302,9 +1315,23 @@ MATCH (n:Function {id: emb.nodeId}) RETURN n`,
: '';
const [processRes, clusterRes, directClusterRes] = await Promise.all([
executeQuery(processQuery),
executeQuery(clusterQuery),
directClusterQuery ? executeQuery(directClusterQuery) : Promise.resolve([]),
executeQuery(processQuery).catch((err) => {
processQueryFailed = true;
if (import.meta.env.DEV) console.warn('Impact process enrichment failed:', err);
return [];
}),
executeQuery(clusterQuery).catch((err) => {
clusterQueryFailed = true;
if (import.meta.env.DEV) console.warn('Impact cluster enrichment failed:', err);
return [];
}),
directClusterQuery
? executeQuery(directClusterQuery).catch((err) => {
clusterClassificationFailed = true;
if (import.meta.env.DEV) console.warn('Impact cluster enrichment failed:', err);
return [];
})
: Promise.resolve([]),
]);
const directClusterSet = new Set<string>();
@ -1323,7 +1350,11 @@ MATCH (n:Function {id: emb.nodeId}) RETURN n`,
affectedClusters = clusterRes.map((row: any) => {
const label = Array.isArray(row) ? row[0] : row.label;
const hits = Array.isArray(row) ? row[1] : row.hits;
const impact = directClusterSet.has(label) ? 'direct' : 'indirect';
const impact = clusterClassificationFailed
? 'classification-unavailable'
: directClusterSet.has(label)
? 'direct'
: 'indirect';
return { label, hits, impact };
});
}
@ -1331,19 +1362,25 @@ MATCH (n:Function {id: emb.nodeId}) RETURN n`,
const directCount = depth1.length;
const processCount = affectedProcesses.length;
const clusterCount = affectedClusters.length;
let risk = 'LOW';
if (directCount >= 30 || processCount >= 5 || clusterCount >= 5 || totalAffected >= 200) {
risk = 'CRITICAL';
} else if (
directCount >= 15 ||
processCount >= 3 ||
clusterCount >= 3 ||
totalAffected >= 100
) {
risk = 'HIGH';
} else if (directCount >= 5 || totalAffected >= 30) {
risk = 'MEDIUM';
}
const enrichmentCapped = allNodeIds.length > maxIdsForContext;
const unusedAxes = unusedAxesForImpactWalk({
isFileTarget: false,
skipEnrichment: false,
maxChunks: 10,
processQueryFailed,
moduleQueryFailed: clusterQueryFailed,
impactedCount: totalAffected,
enrichmentTruncated: enrichmentCapped,
});
const scored = scoreImpactRisk({
direction,
directCount,
processCount,
moduleCount: clusterCount,
impactedCount: totalAffected,
unusedAxes,
});
const { risk, riskSharedAxes, riskScale } = scored;
// ===== COMPACT TABULAR OUTPUT =====
const lines: string[] = [
@ -1351,22 +1388,42 @@ MATCH (n:Function {id: emb.nodeId}) RETURN n`,
`Confidence: High ${confidenceBuckets.high} | Medium ${confidenceBuckets.medium} | Low ${confidenceBuckets.low}`,
``,
`AFFECTED PROCESSES:`,
...(affectedProcesses.length > 0
? affectedProcesses.map(
(p) =>
`- ${p.label} - BROKEN at step ${p.minStep ?? '?'} (${p.hits} symbols, ${p.stepCount ?? '?'} steps)`,
)
: ['- None found']),
...(processQueryFailed
? ['- Unavailable (enrichment query failed)']
: affectedProcesses.length > 0
? affectedProcesses.map(
(p) =>
`- ${p.label} - BROKEN at step ${p.minStep ?? '?'} (${p.hits} symbols, ${p.stepCount ?? '?'} steps)`,
)
: ['- None found']),
``,
`AFFECTED CLUSTERS:`,
...(affectedClusters.length > 0
? affectedClusters.map((c) => `- ${c.label} (${c.impact}, ${c.hits} symbols)`)
: ['- None found']),
...(clusterQueryFailed
? ['- Unavailable (enrichment query failed)']
: affectedClusters.length > 0
? affectedClusters.map((c) => `- ${c.label} (${c.impact}, ${c.hits} symbols)`)
: ['- None found']),
``,
`RISK: ${risk}`,
`RISK: ${risk} (edit gate — warn on HIGH/CRITICAL)`,
`Shared-axes: ${riskSharedAxes} (File vs symbol compare only; do not waive a HIGH risk warning)`,
`Note: this Graph-RAG surface expands File targets to in-file symbols before enrichment, so process/cluster axes are comparable here when enrichment succeeds. MCP File impact does not.`,
...(riskScale.comparableAcrossKinds
? []
: [
`Note: process/module axes were unused (${riskScale.unusedAxes.map((a) => a.reason).join(', ')}).`,
]),
...(risk === 'UNKNOWN' && (processQueryFailed || clusterQueryFailed)
? ['Note: risk is unresolved because enrichment failed; retry before editing.']
: []),
...(enrichmentCapped
? [`Note: process/cluster enrichment is partial (first ${maxIdsForContext} symbols).`]
: []),
...(clusterClassificationFailed
? ['Note: direct/indirect cluster classification is unavailable.']
: []),
`- Direct callers: ${directCount}`,
`- Processes affected: ${processCount}`,
`- Clusters affected: ${clusterCount}`,
`- Processes affected: ${processQueryFailed ? 'unavailable' : processCount}`,
`- Clusters affected: ${clusterQueryFailed ? 'unavailable' : clusterCount}`,
``,
];
@ -1472,7 +1529,9 @@ relationTypes filter (optional):
Additional output sections:
- Affected processes (with step impact)
- Affected clusters (direct/indirect)
- Risk summary (based on direct callers, processes, clusters)`,
- RISK is the edit gate: warn before edits on HIGH/CRITICAL; UNKNOWN requires retry or corroboration
- Shared-axes risk compares File and symbol targets using direct/total counts only; it never waives the RISK gate
- riskScale notes unavailable process/module axes. This Graph-RAG tool expands File targets to in-file symbols; MCP File impact does not`,
schema: z.object({
target: z.string().describe('Name of the function, class, or file to analyze'),
direction: z

View file

@ -40,6 +40,7 @@ import {
repoIdentity as repoIdentityOf,
type BackendRepo,
type ConnectResult,
type GrepOptions,
type JobProgress,
} from '../services/backend-client';
import { ERROR_RESET_DELAY_MS } from '../config/ui-constants';
@ -671,7 +672,8 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
const backend = {
executeQuery,
search: (query: string, opts?: any) => backendSearch(query, { ...opts, repo }),
grep: (pattern: string, limit?: number) => backendGrep(pattern, repo, limit),
grep: (pattern: string, limit?: number, opts?: GrepOptions) =>
backendGrep(pattern, repo, limit, opts),
readFile: (filePath: string) =>
backendReadFile(filePath, { repo }).then((r) => r.content),
};

View file

@ -37,6 +37,7 @@ export const NODE_COLORS: Record<NodeLabel, string> = {
Constructor: '#10b981', // Emerald - like Function
Template: '#a78bfa', // Violet light - like Type
Route: '#f43f5e', // Rose - like Process
Destination: '#fb7185', // Rose light - like Route, the broker-side counterpart
Tool: '#a855f7', // Purple - like Project
BasicBlock: '#475569', // Slate darker - control-flow node (muted, taint/PDG substrate)
};
@ -79,6 +80,7 @@ export const NODE_SIZES: Record<NodeLabel, number> = {
Constructor: 4, // Like Function
Template: 3, // Like Type
Route: 5, // Like Enum
Destination: 5, // Like Route - the broker-side counterpart
Tool: 5, // Like Enum
BasicBlock: 2, // Tiny - control-flow node (taint/PDG substrate)
};

View file

@ -31,6 +31,22 @@ describe('filterRepoFiles', () => {
expect(r.droppedCount).toBe(4);
});
it('excludes emitted _next output, including the Capacitor/Cordova copy', () => {
// `.next` was listed but `_next` was not, so a mobile-wrapped Next.js app
// uploaded its whole minified bundle against the server's caps for files
// the analyzer then discards anyway (#3007).
const input = [
f('repo/android/app/src/main/assets/public/_next/static/chunks/main.js'),
f('repo/ios/App/App/public/_next/static/chunks/framework.js'),
f('repo/_next/static/chunks/x.js'),
f('repo/src/index.ts'),
f('repo/src/_nextgen/index.ts'),
];
const r = filterRepoFiles(input);
expect(r.manifest).toEqual(['repo/src/index.ts', 'repo/src/_nextgen/index.ts']);
expect(r.droppedCount).toBe(3);
});
it('drops files over the per-file size cap', () => {
const input = [f('repo/big.bin', MAX_FILE_BYTES + 1), f('repo/small.ts', 10)];
const r = filterRepoFiles(input);

View file

@ -22,6 +22,17 @@ export const EXCLUDED_DIRS = new Set([
'build',
'out',
'.next',
// `.next` is the build CACHE, `_next` the EMITTED output — different
// directories. A Capacitor/Cordova shell leaves the emitted bundle at
// `<platform>/app/src/main/assets/public/_next/`, so without this the whole
// minified tree is uploaded against the server's file/byte caps only to be
// discarded by the analyzer's own ignore list (#3007).
//
// This pre-filter reads no repository ignore rules, so unlike the CLI walker
// a `.gitnexusignore` negation cannot recover anything dropped here. Names
// added below must therefore stay a subset of the analyzer's own list; see
// `gitnexus/test/unit/upload-filter-ignore-drift.test.ts`.
'_next',
'.nuxt',
'.cache',
'coverage',

View file

@ -64,6 +64,12 @@ export interface GrepResult {
text: string;
}
/** Full `/api/grep` payload — `timedOut` is true when the 5s budget cut the scan short. */
export interface GrepResponse {
results: GrepResult[];
timedOut: boolean;
}
export interface JobProgress {
phase: string;
percent: number;
@ -869,23 +875,37 @@ export const search = async (
return (body.results ?? []) as EnrichedSearchResult[];
};
/** Grep across file contents in the indexed repo. */
/** Options for {@link grep} beyond pattern/repo/limit. */
export interface GrepOptions {
/** Only search files whose path contains this substring (case-insensitive). */
fileFilter?: string | null;
/** Case-sensitive matching (default: insensitive). */
caseSensitive?: boolean;
}
/** Grep across file contents in the indexed repo. Regex semantics server-side. */
export const grep = async (
pattern: string,
repo?: string,
limit?: number,
): Promise<GrepResult[]> => {
opts?: GrepOptions,
): Promise<GrepResponse> => {
const params = [
`pattern=${encodeURIComponent(pattern)}`,
repoParam(repo),
limit ? `limit=${limit}` : '',
opts?.fileFilter ? `fileFilter=${encodeURIComponent(opts.fileFilter)}` : '',
opts?.caseSensitive ? 'caseSensitive=1' : '',
]
.filter(Boolean)
.join('&');
const response = await fetchWithTimeout(`${_backendUrl}/api/grep?${params}`);
await assertOk(response);
const body = await response.json();
return (body.results ?? []) as GrepResult[];
const body = (await response.json()) as Partial<GrepResponse>;
return {
results: body.results ?? [],
timedOut: body.timedOut === true,
};
};
/** Result from reading a file, optionally with line range. */

View file

@ -43,7 +43,7 @@ const FORBIDDEN_TOOL_NAMES = [
const stubBackend: GraphRAGBackend = {
executeQuery: async () => [],
search: async () => [],
grep: async () => [],
grep: async () => ({ results: [], timedOut: false }),
readFile: async () => '',
};

View file

@ -0,0 +1,75 @@
/**
* `/api/grep` client: query params and `timedOut` must reach callers.
* Dropping `timedOut` made a 5s partial scan look like a complete miss.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { __resetBreakerRegistry__ } from 'gitnexus-shared/test-helpers';
import { grep, setBackendUrl } from '../../src/services/backend-client';
const BASE = 'http://grep-client.test:4747';
const jsonOk = (body: unknown) =>
new Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
describe('backend-client grep', () => {
beforeEach(() => {
__resetBreakerRegistry__();
setBackendUrl(BASE);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('forwards fileFilter and caseSensitive and returns timedOut', async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
expect(url).toContain('/api/grep?');
expect(url).toContain(`pattern=${encodeURIComponent('sign|Sign')}`);
expect(url).toContain(`fileFilter=${encodeURIComponent('src/api')}`);
expect(url).toContain('caseSensitive=1');
expect(url).toContain('limit=12');
return jsonOk({
results: [{ filePath: 'src/api.ts', line: 3, text: 'signOrder()' }],
timedOut: true,
});
});
vi.stubGlobal('fetch', fetchMock);
const body = await grep('sign|Sign', '/repo', 12, {
fileFilter: 'src/api',
caseSensitive: true,
});
expect(body.results).toEqual([{ filePath: 'src/api.ts', line: 3, text: 'signOrder()' }]);
expect(body.timedOut).toBe(true);
});
it('reports timedOut false when the server completed the scan', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => {
return jsonOk({ results: [] });
}),
);
const body = await grep('TODO');
expect(body).toEqual({ results: [], timedOut: false });
});
it('does not send fileFilter when it is null or empty', async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
expect(url).not.toContain('fileFilter=');
return jsonOk({ results: [] });
});
vi.stubGlobal('fetch', fetchMock);
for (const fileFilter of ['', null] as const) {
await grep('x', undefined, undefined, { fileFilter });
}
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});

View file

@ -0,0 +1,36 @@
import { describe, expect, it, vi } from 'vitest';
import { createGraphRAGTools, type GraphRAGBackend } from '../../src/core/llm/tools';
const noOpBackend: GraphRAGBackend = {
executeQuery: async () => [],
search: async () => [],
grep: async () => ({ results: [], timedOut: false }),
readFile: async () => '',
};
function grepTool(backend: GraphRAGBackend) {
return createGraphRAGTools(backend).find((candidate) => candidate.name === 'grep')!;
}
describe('grep tool timeout contract', () => {
it('says the scan was incomplete when the server sets timedOut with no hits', async () => {
const grep = vi.fn(async () => ({ results: [], timedOut: true }));
const output = await grepTool({ ...noOpBackend, grep }).invoke({ pattern: 'signOrder' });
expect(output).toContain('No matches for "signOrder"');
expect(output).toContain('results may be incomplete');
});
it('still warns when a timed-out scan returned some hits below the limit', async () => {
const grep = vi.fn(async () => ({
results: [{ filePath: 'a.ts', line: 1, text: 'signOrder()' }],
timedOut: true,
}));
const output = await grepTool({ ...noOpBackend, grep }).invoke({
pattern: 'signOrder',
maxResults: 100,
});
expect(output).toContain('Found 1 matches');
expect(output).toContain('results may be incomplete');
expect(output).not.toContain('Showing first');
});
});

View file

@ -0,0 +1,209 @@
import { describe, expect, it, vi } from 'vitest';
import { createGraphRAGTools, type GraphRAGBackend } from '../../src/core/llm/tools';
const noOpBackend: GraphRAGBackend = {
executeQuery: async () => [],
search: async () => [],
grep: async () => ({ results: [], timedOut: false }),
readFile: async () => '',
};
function impactTool(backend: GraphRAGBackend) {
return createGraphRAGTools(backend).find((candidate) => candidate.name === 'impact')!;
}
describe('Graph-RAG impact risk contract', () => {
it('advertises the edit gate, shared axes, and MCP File difference', () => {
const description = impactTool(noOpBackend).description;
expect(description).toContain('RISK is the edit gate');
expect(description).toContain('Shared-axes risk');
expect(description).toContain('riskScale');
expect(description).toContain('MCP File impact does not');
});
it('renders failed enrichment as unavailable and fails the risk gate closed', async () => {
const executeQuery = vi.fn(async (query: string) => {
if (query.includes("WHERE n.name = 'target'")) {
return [{ id: 'target-id', nodeType: 'Function', filePath: 'src/target.ts' }];
}
if (query.includes('MATCH (affected)-[r:CodeRelation]->(target)')) {
return [
{
id: 'caller-id',
name: 'caller',
nodeType: 'Function',
filePath: 'src/caller.ts',
startLine: 4,
edgeType: 'CALLS',
confidence: 1,
},
];
}
if (query.includes('STEP_IN_PROCESS')) throw new Error('process query failed');
if (query.includes('MEMBER_OF')) return [];
return [];
});
const output = await impactTool({ ...noOpBackend, executeQuery }).invoke({
target: 'target',
direction: 'upstream',
maxDepth: 1,
});
expect(output).toContain('AFFECTED PROCESSES:\n- Unavailable (enrichment query failed)');
expect(output).not.toContain('AFFECTED PROCESSES:\n- None found');
expect(output).toContain('RISK: UNKNOWN');
expect(output).toContain('risk is unresolved because enrichment failed');
expect(output).toContain('- Processes affected: unavailable');
});
it('preserves proved CRITICAL risk when the cluster query fails', async () => {
const executeQuery = vi.fn(async (query: string) => {
if (query.includes("WHERE n.name = 'target'")) {
return [{ id: 'target-id', nodeType: 'Function', filePath: 'src/target.ts' }];
}
if (query.includes('MATCH (affected)-[r:CodeRelation]->(target)')) {
return [
{
id: 'caller-id',
name: 'caller',
nodeType: 'Function',
filePath: 'src/caller.ts',
edgeType: 'CALLS',
confidence: 1,
},
];
}
if (query.includes('STEP_IN_PROCESS')) {
return Array.from({ length: 5 }, (_, index) => ({
label: `process-${index}`,
hits: 1,
minStep: index + 1,
stepCount: 5,
}));
}
if (query.includes('MEMBER_OF') && query.includes('COUNT(DISTINCT s.id)')) {
throw new Error('cluster query failed');
}
if (query.includes('MEMBER_OF')) return [];
return [];
});
const output = await impactTool({ ...noOpBackend, executeQuery }).invoke({
target: 'target',
direction: 'upstream',
maxDepth: 1,
});
expect(output).toContain('RISK: CRITICAL');
expect(output).toContain('AFFECTED CLUSTERS:\n- Unavailable (enrichment query failed)');
expect(output).toContain('- Processes affected: 5');
expect(output).toContain('- Clusters affected: unavailable');
});
it('does not invent direct/indirect cluster classification after its query fails', async () => {
const executeQuery = vi.fn(async (query: string) => {
if (query.includes("WHERE n.name = 'target'")) {
return [{ id: 'target-id', nodeType: 'Function', filePath: 'src/target.ts' }];
}
if (query.includes('MATCH (affected)-[r:CodeRelation]->(target)')) {
return [
{
id: 'caller-id',
name: 'caller',
nodeType: 'Function',
filePath: 'src/caller.ts',
edgeType: 'CALLS',
confidence: 1,
},
];
}
if (query.includes('STEP_IN_PROCESS')) return [];
if (query.includes('MEMBER_OF') && query.includes('RETURN DISTINCT')) {
throw new Error('classification query failed');
}
if (query.includes('MEMBER_OF')) return [{ label: 'Core', hits: 1 }];
return [];
});
const output = await impactTool({ ...noOpBackend, executeQuery }).invoke({
target: 'target',
direction: 'upstream',
maxDepth: 1,
});
expect(output).toContain('- Core (classification-unavailable, 1 symbols)');
expect(output).toContain('direct/indirect cluster classification is unavailable');
expect(output).not.toContain('process/module axes were unused');
});
it('treats successful File expansion as comparable because enrichment runs on member symbols', async () => {
const executeQuery = vi.fn(async (query: string) => {
if (query.includes("n.filePath CONTAINS 'src/target.ts'")) {
return [{ id: 'file-id', nodeType: 'File', filePath: 'src/target.ts' }];
}
if (query.includes("callee.filePath = 'src/target.ts'")) {
return [
{
id: 'caller-id',
name: 'caller',
nodeType: 'Function',
filePath: 'src/caller.ts',
edgeType: 'CALLS',
confidence: 1,
},
];
}
if (query.includes('STEP_IN_PROCESS')) {
return [{ label: 'Build', hits: 1, minStep: 1, stepCount: 1 }];
}
if (query.includes('MEMBER_OF') && query.includes('RETURN DISTINCT')) {
return [{ label: 'Core' }];
}
if (query.includes('MEMBER_OF')) return [{ label: 'Core', hits: 1 }];
return [];
});
const output = await impactTool({ ...noOpBackend, executeQuery }).invoke({
target: 'src/target.ts',
direction: 'upstream',
maxDepth: 1,
});
expect(output).toContain('process/cluster axes are comparable here when enrichment succeeds');
expect(output).toContain('- Processes affected: 1');
expect(output).toContain('- Clusters affected: 1');
expect(output).not.toContain('process/module axes were unused');
});
it('surfaces the 500-symbol enrichment cap as partial', async () => {
const executeQuery = vi.fn(async (query: string) => {
if (query.includes("WHERE n.name = 'target'")) {
return [{ id: 'target-id', nodeType: 'Function', filePath: 'src/target.ts' }];
}
const depth = query.includes('3 AS depth') ? 3 : query.includes('2 AS depth') ? 2 : 1;
if (query.includes('CodeRelation') && query.includes(` ${depth} AS depth`)) {
return Array.from({ length: 200 }, (_, index) => ({
id: `d${depth}-${index}`,
name: `node-${depth}-${index}`,
nodeType: 'Function',
filePath: `src/d${depth}-${index}.ts`,
edgeType: 'CALLS',
confidence: 1,
}));
}
if (query.includes('STEP_IN_PROCESS') || query.includes('MEMBER_OF')) return [];
return [];
});
const output = await impactTool({ ...noOpBackend, executeQuery }).invoke({
target: 'target',
direction: 'upstream',
maxDepth: 3,
});
expect(output).toContain('process/cluster enrichment is partial (first 500 symbols)');
expect(output).toContain('enrichment-truncated');
expect(output).not.toContain('enrichment-budget-exhausted');
});
});

View file

@ -4,6 +4,97 @@ All notable changes to GitNexus will be documented in this file.
## [Unreleased]
## [1.6.10] - 2026-08-27
### Added
- **Spring framework modeling expanded end to end** — AOP transactions, caching and security (#2783), `@Bean` factories and `@Resource` injection (#2740), profiles/conditions/auto-configuration (#2678), constructor and standard injection (#2632), bean candidate inventory (#2494), configuration-property consumers, and non-HTTP handler entry points (#2891)
- **Receiver chains typed from AST structure across all 14 languages**, with an explicit epistemic lower bound on what the graph can claim (#2708, #2744, #2747)
- **Java enum constant bodies modeled as first-class instances**, with JLS 13.1 anonymous-class naming (#2558)
- **More route surfaces indexed** — Java constant-based route paths such as `@PostMapping(ApiPathConstants.X)` (#2980) and JavaScript data route tables (#2972)
- **MCP server hardening** — repository allowlist, fail-closed read-only mode, deterministic output budgets, and normalized `impact`/`context` aliases
- **`bunx` lane so bun-only machines can run GitNexus** (#2765)
- **Codex support** — hooks, plugin marketplace and setup (#2328, #2369) — plus CodeBuddy and Qoder coding-agent integrations (#2368)
- **Skills mirrored to `.agents/skills/`** when an `.agents/` directory exists
- **One-click Render deploy** (#2804)
- **`serve` origin/proxy configuration is validated and port-scoped** (#2820)
- **Expanded TypeScript/JavaScript taint sink model** (#2490)
- **Wiki generation accepts explicit HTTP LLM hosts** (#2491)
- **Embedding request-body dimensions configurable** via `GITNEXUS_EMBEDDING_REQUEST_DIMS` (#2574)
- **Refreshed MiniMax model and endpoint configuration** (#2780)
- **`MAX_CALLABLE_VALUE_TARGETS` and `MAX_PROPERTY_DISPATCH_FANOUT` configurable via env** (#2725, #2726)
- **Opt-in `analyze --self-commit`** for AGENTS.md/CLAUDE.md churn (#2640)
- **Buffer pool sized to the graph before the database opens**, with an adaptive size hint
- **CI review agent runs as a coordinated reviewer swarm** on Sonnet 5 with structured, linked reviews (#2570, #2572), alongside the GitNexus Engineering Tool Kit skills (#2566) and an online skill-evolution loop (#2571)
- **Icebug community-engine prototype behind a gate** (#2376)
### Fixed
- **`group sync` stops claiming matching it never did** — the advertised BM25/embedding cascade was config, help text and MCP schema with no matcher behind it; the unread `matching.bm25_threshold`, `matching.embedding_threshold`, `detect.embedding_fallback` and `--skip-embeddings` surfaces are removed (#3020)
- **Emitted Next.js build output is ignored during ingestion**, and the inert `public/build` entry is deleted (#3018)
- **NestJS decorator routes are indexed** so `api_impact` and `route_map` stop reporting live endpoints as non-existent (#3017)
- **Import resolution gated by real module configuration** instead of path-suffix guessing — TypeScript config (#2953, #2956), Java and Kotlin declared packages (#2955, #2990), Go module paths (#2984), PHP Composer autoload maps (#2987), Python `__init__.py` re-exports (#2864) and unaliased dotted namespace imports (#2826, #2828), and JavaScript module extensions (#3034)
- **Interface dispatch is generic-instantiation aware** (#2912, #2939), fans out from Case 3b receivers (#2832, #2842) and from C# record interface calls (#2904), and resolves through generic-typed field receivers in every language (#2833, #2855)
- **Go method sets modeled exactly** so interface satisfaction is decidable (#2813, #2829), out-of-repo package qualifiers resolve, and an undecided interface check is no longer reported as a decided negative (#2873, #2921)
- **Go pointer-receiver calls resolve**, reporting the program boundary instead of hedging (#2766, #2782)
- **Java record support** — graph nodes for `record_declaration`, component accessors, enum and record interface heritage (#2564, #2916, #2935, #2936), plus `E.CONST.method()` enum-constant receiver dispatch (#2561) and JLS binary-name identities for local classes, enums, records and interfaces (#2562, #2653)
- **Rust module-qualified calls resolve against the module tree** (#2730, #2741), items are qualified by their enclosing `mod` chain (#2742, #2745), duplicate type names stay ambiguous in range binding (#2514, #2652), and `Box<dyn Trait>` names normalize
- **Closure bindings are call sources in every language**, and function-local values carry their own identity (#2693, #2695, #2699, #2718)
- **A named receiver's member never resolves lexically** (#2714), platform builtins stop resolving to unrelated same-file symbols (#2549), and inline constructor receivers are typed in every spelling (#2708, #2737)
- **Python calls resolve through constructor-injected fields** (#2628) and module-imported classes (#2770)
- **Package directories that repeat higher in the path resolve correctly** (#2881, #2929)
- **`check` stops reporting erased and deferred imports as initialization cycles** (#2934)
- **`detect_changes` no longer scales its query with the diff's hunk count** (#2915, #2930), and CR-only line-ending diffs are ignored (#2839)
- **`group` stops reporting what could not be measured as a measurement of zero** (#3012), resolves HTTP consumers through configured clients and constant route tables (#3008), and preserves manifest-only impact crossings (#2784)
- **`impact` and `context` are reproducible** — deterministic ordering on every capped query (#2787, #2796) — and Convex caller results are marked incomplete rather than empty (#3044)
- **Object handler identity is preserved** during ingestion (#3046), nested source directories are discovered (#3043), and parse-node insertion is canonicalized
- **Large-repo analyze OOM and the false worker-timeout cascade are fixed** (#2649, #2679)
- **Single-writer lock on the index write path** (#2658, #2677), atomic index swap with read-pool staleness invalidation (#2614), and reliable large incremental writeback commits (#2409, #2425)
- **Remote URLs are stripped of credentials before they are persisted** (#2914, #2928), and every registry write gets its own tmp path (#2888, #2920)
- **Schema version derived from a DDL fingerprint** instead of a hand-incremented constant (#2798, #2808), and the scope-resolution relation cross product is fully declared (#2792, #2793)
- **FTS reliability** — binary payloads stay out of the description column and an unbuildable index is confined to its own table (#2919), FTS-indexed DML is gated before the incremental writeback (#2841, #2854), analyze degrades instead of aborting on index-build failure (#2548), real LOAD errors surface and broken extension files self-heal (#2374, #2375), and Windows missing-dependency load failures are diagnosed (#2383)
- **`VECTOR` is loaded only when needed** (#3045) and before the incremental writeback touches embedding rows (#2623, #2624)
- **Buffer pool bounded instead of taking the native 80%-of-RAM default** (#2560), scaled by the OS page-size granule ratio (#2631, #2636), with a COPY-safe floor and actionable diagnostics for non-4K page sizes (#2424)
- **`Napi::Error` SIGABRT on analyze eliminated** — C++ type lookups are indexed and workers terminate only at JS-safe points (#2432, #2436)
- **Native-load failures fail closed**, including truncated-binary SIGBUS (#2441, #2651), and glibc-too-old loads are no longer misdiagnosed (#2672, #2689)
- **Index staleness reporting fixed** — no false-stale status after analyze, with inline staleness in `query`/`context`/`impact`/`cypher` (#2655, #2668, #2683)
- **Windows path handling** — the `\\?\` long-path prefix no longer breaks repo path matching (#2667, #2700), `parts` negation is honored (#2720), and missing-shadow errors let `serve` repo-switch recover (#2382, #2387)
- **Embeddings survive partial failures** — unparseable 200 responses are retried (#2790, #2795), batch inserts are retry-safe (#2453), HTTP generation is resumable, resume checkpoints bind to their provider, and proxy-blocked installs self-heal (#2370, #2372)
- **Custom HTTP embedding endpoint failures are reported as themselves**, not as Hugging Face download errors (#2385, #2386)
- **Exact symbol content with 0-based line storage and 1-based MCP display** (#2377, #2379, #2380)
- **`rename` reports every edit that apply writes** and reconciles its report on partial failure (#2605)
- **Global registry transactions serialized across processes** (#2716)
- **Swift indented conditional directives are preprocessed** so class bodies survive parsing (#2771), and Swift member-containment pairs are declared in the `CONTAINS` DDL (#2769)
- **JavaScript `exports.foo = function () {}` CommonJS exports are indexed** (#2723, #2729), and `const X = () => {}` is no longer double-indexed as a Function plus an edgeless Const twin (#2687, #2691)
- **JVM sibling injection is proximity-bounded** (#2732), and C#/Kotlin free calls are gated by instance ownership (#2563, #2654)
- **Dart extension type symbols are extracted** (#2539), and declarations recover after embedded NUL bytes (#2430)
- **CLI and hooks fail loudly on backend error payloads**, with an MCP query hint when the server owns the DB lock (#2396, #2397)
- **Committed agent guides stop churning**, with an `--index-only` nudge (#2907, #2927), and `gitnexus-plan` artifacts publish on macOS without an interpreter (#2905, #2922)
- **The 300-flows cap is removed for large repositories** (#2198)
### Changed
- **BREAKING: Node `^22.18.0 || >=24.11.0` is now the supported floor**; the `@types/uuid` stub is dropped
- **BREAKING: the non-functional `group` matching knobs are gone**`matching.bm25_threshold`, `matching.embedding_threshold`, `detect.embedding_fallback` in `group.yaml`, the `gitnexus group sync --skip-embeddings` flag, and the MCP `group_sync` `skipEmbeddings` argument (#3020)
- **Structural relationships are held out of the JS heap by default** during analyze (#2680, #2685)
- **Global ignore support**`core.excludesFile`, `.git/info/exclude`, and a user-level global ignore file are honored (#2606)
- **Plugin manifests sync on every version bump** (#2445), and planning output under `docs/plans` is no longer tracked
### Performance
- **Import resolution indexed instead of scanned** — every scanning resolver with a consolidated memo (#2911), a per-run workspace index for Go/C#/Dart/Ruby (#2898), and Kotlin import resolution (#2872)
- **MCP server startup drops the analyze-only language-provider closure** (#2802, #2806)
- **C++ qualified namespace members indexed once per pipeline run** (#2788, #2794)
- **Vendored Leiden O(communities × N) copy removed**, with Icebug wired to its real API (#2337, #2692)
- **`core.excludesFile` / `info/exclude` resolution memoized** (#2606)
### Chore / Dependencies
- **`@ladybugdb/core` bumped to ^0.18.3** for the rel-property IN-predicate fix (#2508, #2634)
- **Security overrides**`sharp` >=0.35.0 for libvips vulnerabilities (#2993) and `adm-zip` >=0.6.0 for a memory-allocation vulnerability (#2992)
- **~130 dependency bumps** across the CLI, web app and GitHub Actions, including `@modelcontextprotocol/sdk`, LangChain, Vite, Vitest, TypeScript, React and the Docker/CodeQL action suite
- **CI hardening** — Windows shard watchdog widened with exit diagnostics (#2449), platform-sensitive matrix sharded to fix the Windows cross-platform timeout (#2394), and CI Report no longer dies silently when the tests job fails (#2728)
## [1.6.9] - 2026-07-04
### Added

View file

@ -204,7 +204,7 @@ Your AI agent gets **17 tools** (15 per-repo + 2 group) automatically:
| `group_list` | List configured repository groups |
| `group_sync` | Rebuild a group's Contract Registry and cross-repo links |
> With one indexed repo, the `repo` param is optional. With multiple, specify which: `query({search_query: "auth", repo: "my-app"})`. Per-repo tools also take an optional `branch` for indexes pinned with `gitnexus analyze --branch`; omitting it queries the workspace index, which follows your checked-out working tree. `explain` and `pdg_query` need an index built with `gitnexus analyze --pdg`.
> Read-only tools can omit `repo` when one repo is indexed, an MCP default is configured, or the GitNexus process cwd is inside a registered path without crossing into an unindexed nested Git checkout. Otherwise—and for mutating tools with multiple indexed repos and no MCP default—specify it explicitly: `query({search_query: "auth", repo: "my-app"})`. Per-repo tools also take an optional `branch` for indexes pinned with `gitnexus analyze --branch`; omitting it queries the workspace index, which follows your checked-out working tree. `explain` and `pdg_query` need an index built with `gitnexus analyze --pdg`.
## MCP Resources
@ -234,19 +234,22 @@ Your AI agent gets **17 tools** (15 per-repo + 2 group) automatically:
gitnexus setup # Configure MCP for detected editors (one-time; use -c to select)
gitnexus uninstall # Preview removal of GitNexus MCP/skills/hooks (add --force to apply)
gitnexus analyze [path] # Index a repository (or update stale index)
gitnexus analyze [path] --watch # Watch local files and serialize incremental refreshes
gitnexus analyze --repair-fts # Fast path: rebuild/verify only FTS indexes on existing index data
gitnexus analyze --force # Full rebuild: re-parse + graph rebuild + FTS rebuild
gitnexus analyze --embeddings # Enable embedding generation (slower, better search)
gitnexus embeddings install # Fetch the optional local embedding stack on demand (--cuda, --force)
gitnexus analyze --skills # Generate repo-specific skill files from detected communities
gitnexus analyze --skip-agents-md # Preserve custom AGENTS.md/CLAUDE.md gitnexus section edits
gitnexus analyze --skip-agents-md # Preserve custom AGENTS.md/CLAUDE.md gitnexus section edits (does not skip standard skills; use --skip-skills; community --skills files are unaffected)
gitnexus analyze --skip-skills # Skip installing standard .claude/skills/gitnexus-* skill files
gitnexus analyze --skip-git # Index folders that are not Git repositories
gitnexus analyze --workers <n> # Parse worker pool size (>=1; default: cores-1, capped at 16)
gitnexus analyze --spring-actuator ./actuator # Enrich with local Spring Boot Actuator JSON snapshots
gitnexus analyze --verbose # Log skipped files when parsers are unavailable
gitnexus analyze --max-file-size 1024 # Skip files larger than N KB (default: 512, cap: 32768)
gitnexus analyze --worker-timeout 60 # Increase worker idle timeout for slow parses
gitnexus analyze --wal-checkpoint-threshold 67108864 # 64 MiB. Control LadybugDB WAL auto-checkpoint threshold (default: 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB)
gitnexus auto-sync [init|start|restart|stop|status|reset] # Scheduled remote clone/pull + analyze from GITNEXUS_HOME/watch_config.yml
gitnexus mcp # Start MCP server (stdio) — serves all indexed repos
gitnexus serve # Start local HTTP server (multi-repo) for web UI
gitnexus index # Register an existing .gitnexus/ folder into the global registry
@ -256,6 +259,7 @@ gitnexus clean # Delete index for current repo
gitnexus clean --all --force # Delete all indexes
gitnexus wiki [path] # Generate LLM-powered docs from knowledge graph
gitnexus wiki --model <model> # Wiki with custom LLM model (default: minimax/minimax-m2.5)
gitnexus wiki --provider grok # Local Grok Build CLI (uses `grok login`, no API key)
gitnexus wiki --base-url http://llama-box.local:8080/v1 --allow-insecure-connection llama-box.local
# Allow an exact LAN/self-hosted HTTP LLM host; env: GITNEXUS_ALLOW_INSECURE_CONNECTION
gitnexus doctor # Show runtime platform capabilities and embedding configuration
@ -281,6 +285,70 @@ gitnexus group status <name> # Check staleness of repos in a group
gitnexus group impact <name> --target <symbol> --repo <groupPath> # Cross-repo blast radius
```
`gitnexus analyze --watch` requires a Git repository. It performs an initial
analysis and then debounces scanner-admitted working-tree changes for 300 ms by
default into serialized incremental refreshes. Events arriving during a run
remain queued, and retryable failures retain the same batch with bounded
backoff. Invalid `.gitnexusrc` or ignore-file reloads pause ordinary refreshes
until the control file is fixed. Watch refreshes update only the graph: they
intentionally skip AGENTS.md / CLAUDE.md injection and standard skill
installation. Run a one-shot `gitnexus analyze` when those generated files need
updating. Stop watch mode with Ctrl+C.
Watch mode accepts `--debounce`, `--workers`, `--worker-timeout`,
`--max-file-size`, `--branch`, `--pdg`, `--name`, `--allow-duplicate-name`, and
`--verbose`. Explicit one-shot options such as `--force`, `--repair-fts`,
embedding flags, `--skills`, `--default-branch`, `--skip-agents-md`,
`--skip-skills`, `--no-stats`, `--self-commit`, `--index-only`, and `--skip-git`
are rejected. Unsupported defaults from `.gitnexusrc` are ignored with a warning.
POSIX requests clone-first copy-and-swap publication when the live index has no
orphan sidecars. Windows and sidecar fallback runs update in place: failures
known to occur before writes are retried, while a failure that may have mutated
the live index stops the watcher. Watch mode does not pull remotes. Running MCP
and `serve` processes periodically check for a newly published index and reopen
it without a restart. MCP checks are throttled to once every five seconds, so a
tool call before the next check can briefly use the previous index.
### `gitnexus auto-sync`
`gitnexus auto-sync` is a different product from `gitnexus analyze --watch`. It is the explicit long-running auto-sync entrypoint that clones or pulls configured remotes. `gitnexus watch` is reserved and does not start either job: it prints this split. `GITNEXUS_HOME` defaults to `~/.gitnexus`; `gitnexus auto-sync init` creates its default `$GITNEXUS_HOME/watch_config.yml`. Bare `gitnexus auto-sync` is the same as `gitnexus auto-sync start`; `restart`, `stop`, `status`, and `reset` manage the same `GITNEXUS_HOME` instance. `reset` removes only the derived analysis state and commit snapshot; clones, indexes, and registry entries are untouched. `start` runs in the foreground, reads the configuration once at startup, runs once immediately, then repeats on `sync_interval_minutes`; restart it after changing the configuration. Watch runtime artifacts live under `$GITNEXUS_HOME/watch/`: `project_commit_info.txt` is the human-readable per-loop snapshot, `auto-sync-state.json` is the machine state used for commit skipping and analyze failure thresholds, `watch.mutex` prevents multiple auto-sync processes for one home, `watch.owner.json` records ownership metadata, `watch.pid` plus `watch.status.json` expose process state, `watch.stop.<ownerId>.json` is a temporary owner-fenced stop request, and `quarantine/` stores partial clone output before entries are removed after 14 days, keeping at most the five newest entries per repository regardless of age. Mutexes with verified dead owners are reclaimed automatically after an abnormal exit. Invalid or legacy mutexes fail closed; confirm no auto-sync process is running before manually removing `watch.mutex` and stale `watch.pid` / `watch.owner.json`.
```yaml
sync_interval_minutes: 10
max_concurrency: 1
repo_git_timeout: 10s
analyze_timeout: 5m
analyze_failure_threshold: 3
projects:
- local_path: /abs/path/to/repos
branches: [master, main]
overwrite_local_changes: false
remote_urls:
- git@github.com:owner/repo.git
- git@gitlab.com:group/repo.git
- git@gitee.com:owner/repo.git
```
`sync_interval_minutes` must be an integer of at least `5`. `local_path` must be an absolute path without traversal; each remote is cloned below it as `host/namespace/repo`, preventing same-basename repositories from colliding. `remote_urls` must use SSH SCP form for github.com, gitlab.com, or gitee.com. `repo_git_timeout` applies to each repo clone/pull and defaults to `10s`; a bare number such as `10` is interpreted as seconds, while `10000ms`, `10s`, and `1m` keep their explicit units. It must not exceed one hour or `sync_interval_minutes`, whichever is smaller — so a bare `600000` is rejected, because it means 600000 seconds rather than milliseconds. `analyze_timeout` applies to each isolated analysis worker, defaults to half of `sync_interval_minutes`, and cannot exceed that value; this keeps it within Node's timer range. Timeout and `auto-sync stop` request safe cancellation; a worker already in native work exits after it returns to a JS-visible safe point. While waiting, auto-sync reports `cancelling` or `stopping` and keeps its ownership files so another auto-sync cannot take over. The parent waits up to 5 seconds for the worker to exit; after that it stops waiting, releases its ownership files, and leaves the worker to finish and exit on its own rather than killing it mid-write. `auto-sync stop` uses this same control path on macOS and Windows. `overwrite_local_changes` defaults to `false`; a dirty local clone is skipped with an error log, while `true` allows branch fallback to replace local changes and additionally discards untracked files and directories in the clone after checkout — ignored paths, including GitNexus's own `.gitnexus/` storage, are preserved. `max_concurrency` defaults to `1` and is capped at runtime by `floor(availableMemoryGB / 2)` with a minimum of `1`; the effective value is printed at the start of each loop. Each analysis worker's heap cap is the machine-wide cap divided by the number of repositories analyzed in parallel, so concurrent workers share one memory budget instead of each claiming the whole machine. `analyze_failure_threshold` defaults to `3`, must be at least `2`, and pauses repeated failures only for the same repo branch and commit; a new commit or `gitnexus auto-sync reset` clears the block and allows analysis again. Repositories are registered and added to groups by their full remote identity (`host/namespace/repo`), so repositories with the same basename remain distinct. Use `branches` to try branches in order; legacy `branch` remains supported, but the two fields cannot be set together. If all branches are unavailable or time out, watch logs an error, records the repo status, and skips that repo for the loop. Leave `group_name` empty or omit it to skip group add/sync for that project; otherwise create the group first with `gitnexus group create <name>`. `$GITNEXUS_HOME/watch/project_commit_info.txt` is for inspection only; GitNexus stores machine state separately in `$GITNEXUS_HOME/watch/auto-sync-state.json`.
GraphQL contract matching is opt-in in the group's `group.yaml`:
```yaml
detect:
graphql: true
```
The initial exact-only slice matches methods and properties on top-level NestJS `@Resolver`
classes using imported `@Query`, `@Mutation`, and `@Subscription` decorators. Named
`.graphql`/`.gql` operations are anchored by generated `<OperationName>Document` declarations;
object, static `gql` template, and `TypedDocumentString` initializers must prove the operation name
and root fields. Dynamic decorator names, anonymous operations, and ambiguous or missing graph
anchors are deliberately omitted. Add common infrastructure fields such as `/health` to
`matching.exclude_links_paths` to keep those GraphQL contracts visible without cross-linking them.
`--spring-actuator` is explicitly opt-in. The path may be a JSON bundle keyed by `mappings`, `beans`, `conditions`, `configprops`, and/or `env`, or a directory containing endpoint-named JSON files. Runtime mappings and beans confirm matching static nodes; conditions and configuration property keys enrich existing evidence, with conservative runtime-only nodes added when no match exists. The configured input is excluded from source scanning; only normalized repository-relative exclusions are retained for future scans, never absolute paths. Env/configprops values, origins, condition messages, and source names are never persisted or printed. Enabled runs always rebuild because runtime snapshots are external to git freshness; omitting the option later rebuilds once to remove runtime evidence. Project config can set the same path with `springActuator` in `.gitnexusrc`.
> **`gitnexus uninstall`** reverses `gitnexus setup` — it removes the GitNexus MCP entries, hooks, and skill directories it added to each detected editor. Skill directories are identified **by bundled gitnexus skill name** (e.g. `gitnexus-cli/`), so if you customized files inside an installed skill directory, back them up first. It is a dry-run preview by default and prints the exact paths it would remove; pass `--force` to apply. Per-repo indexes (`gitnexus clean --all`) and the global npm package (`npm uninstall -g gitnexus`) are left for you to remove.
## Remote Embeddings
@ -390,7 +458,7 @@ Installed automatically by both `gitnexus analyze` (per-repo) and `gitnexus setu
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*
- **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).
@ -667,17 +735,17 @@ 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). |
| `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. |
| 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
@ -691,8 +759,8 @@ Programmatic callers can pass `keepLocalValueSymbols: true` in `PipelineOptions`
### Scope-resolution property-key dispatch cap
During scope resolution GitNexus synthesizes CALLS edges through *property-key
dispatch* — call sites like `hooks.emitScopeCaptures()` where a property key is
During scope resolution GitNexus synthesizes CALLS edges through _property-key
dispatch_ — call sites like `hooks.emitScopeCaptures()` where a property key is
registered by multiple definitions across the codebase. To keep this fan-in
bounded, each property key is capped at **32 registrations**: a key registered
by more than 32 distinct functions is skipped entirely (no CALLS are synthesized
@ -700,8 +768,8 @@ through it), and the dropped key names are surfaced in the analyze log for
operator visibility. The cap is calibrated at 2× this repo's own provider table
(16 legitimate registrations, one per language provider).
| Variable | Default | Effect |
| --------------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Variable | Default | Effect |
| --------------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GITNEXUS_MAX_PROPERTY_DISPATCH_FANOUT` | `32` | Per-property-key registration cap in the property-dispatch scope-resolution pass. Set to a positive integer to raise it for repositories whose provider/hook tables exceed the default and lose CALLS coverage on a legitimate key; non-integer or `< 1` values fall back to `32`. Lowering it tightens the overflow budget. |
```bash
@ -714,11 +782,11 @@ npx gitnexus analyze --force
### Scope-resolution dispatch-target cap
During scope resolution GitNexus resolves calls that flow through *callable
values* — function/method references bound to variables, passed as arguments,
During scope resolution GitNexus resolves calls that flow through _callable
values_ — function/method references bound to variables, passed as arguments,
or stored in maps/tables. To keep that inclusion-based resolution finite, each
callable site is capped at **32 dispatch targets**. When a site gathers more
candidates than the cap it is treated as **overflowed** and *all* of its call
candidates than the cap it is treated as **overflowed** and _all_ of its call
edges are dropped — a cliff, not a tail, so a repository with a legitimately
wide dispatch table (a single callable site resolving to 33+ targets) loses
that site's whole call chain. In that case `analyze` logs
@ -728,8 +796,8 @@ candidate count, and the cap (32).
Raise the cap for such repositories:
| Variable | Default | Effect |
| ------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Variable | Default | Effect |
| ------------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GITNEXUS_MAX_CALLABLE_VALUE_TARGETS` | `32` | Per-callable-site dispatch-target cap in the callable-value-flow scope-resolution pass. Set to a positive integer to raise it for repositories whose wide dispatch tables overflow the default and lose a whole call chain; non-integer or `< 1` values fall back to `32`. Lowering it tightens the overflow budget. |
```bash

View file

@ -58,9 +58,6 @@ packages: {}
detect:
http: true
matching:
bm25_threshold: 0.7
embedding_threshold: 0.65
max_candidates_per_step: 3
`;
}

View file

@ -1,7 +1,11 @@
{
"fingerprint": "4ee15e742a9839671a900df4f57c1c91196c64256c8cab2ac445bec605a092d5",
"fingerprint": "72096279092d4f118de7e179333705c19c9aff2664f77d71a7da48cd9f73fb5a",
"scaling_budget": 1.8,
"max_ms_large": 1000,
"_rebaselined_destination_broker_conflict_column_removed": "The `Destination` node table lost its trailing `brokerConflict` STRING column (DESTINATION_SCHEMA in src/core/lbug/schema.ts, the COPY statement in lbug-adapter.ts, and the `destinationWriter` header plus its row cell in csv-generator.ts). The column existed only to say WHY a destination's address had been withdrawn when two brokers claimed it; the address is no longer withdrawn — a resolved `Destination` is now keyed by `(broker, address)` via `ingestion/destination-key.ts`, so two brokers on one name are two ordinary joinable nodes and there is nothing to diagnose. That makes this a header-only shrink, and it was verified as one rather than assumed: dumping every CSV this bench emits with csv-generator.ts at the merge base and again on this branch, then diffing per file by filename, byte length and sha256, shows the file SET identical at 36 CSVs on both sides, 35 of the 36 byte-IDENTICAL (same sha256, not merely same length), and the sole difference `destination.csv` shrinking 112 -> 97 bytes: `id,name,filePath,startLine,endLine,address,broker,resolution,configKey,configDefault,brokerConflict,description` -> the same list without `brokerConflict`. That file is header-only on both sides — the synthetic benchmark graph contains no Destination nodes — so no row moved, was re-routed to another pair file, or reordered, which is the class of change this fingerprint exists to catch. Prior 4b339233662b0eebb236738abad8f7c039b9930bc1222dcad7b814afa6332fbf -> 72096279092d4f118de7e179333705c19c9aff2664f77d71a7da48cd9f73fb5a, reproduced identically across two consecutive runs. Both timing gates passed while the guard was red (scaling_ratio 0.818 and 0.751 across those two runs against the 1.8 budget; elapsed_ms_large 65.64ms and 59.32ms against the 1000ms backstop), so no throughput claim is being rebaselined away.",
"_rebaselined_3132_destination_node_table": "A new `Destination` node table (async messaging overlay; see DESTINATION_SCHEMA in src/core/lbug/schema.ts) means `streamAllCSVsToDisk` writes one more FILE, not one more column — the first rebaseline here that changes the file SET rather than a header. That makes the usual evidence more important, not less, so it was gathered the same way: dump every CSV this bench emits on the merge base and on this branch, then diff per-file by filename, byte length and sha256. Result: 35 files -> 36, the sole addition is `destination.csv`, and ALL 35 pre-existing files are byte-IDENTICAL — not merely same-length, same sha256. So nothing was re-routed into the new file and nothing reordered, which is exactly what this fingerprint exists to catch. `destination.csv` is 112 bytes, header only: the synthetic benchmark graph has no Destination nodes, so no row exists to move. Prior 7b2ec01a110dcbc66868fba2c97714aaece8c3864c2eba00df68b5c027f034d2 -> 4b339233662b0eebb236738abad8f7c039b9930bc1222dcad7b814afa6332fbf, reproduced identically across two runs. Both timing gates passed while this was red (scaling_ratio 0.711 vs the 1.8 budget, elapsed_ms_large 58.88ms vs the 1000ms backstop), so no throughput claim is being rebaselined away.",
"_rebaselined_2856_property_is_detail": "Third and last of the bench guards this branch left red. The Property node table gained an `isDetail` BOOLEAN column (see PROPERTY_SCHEMA in src/core/lbug/schema.ts), so `streamAllCSVsToDisk` writes one more header field and one more cell per Property row — csv-generator.ts `propertyHeader` and the `node.label === 'Property'` tail. Verified to be header-only drift rather than a change in what is emitted: dumping every CSV this bench produces on `origin/main` and on this branch and diffing per-file (filename, byte length, sha256) shows the file SET is identical at 35 CSVs on both sides, 34 of the 35 are byte-identical, and the sole difference is `property.csv` growing 68 -> 77 bytes, `id,name,filePath,startLine,endLine,content,description,declaredType` -> `...,declaredType,isDetail`. The synthetic graph has no Property nodes, so no ROW moved at all. That is the check that matters here: a row routed to the wrong pair file, or a within-file reordering, is what this fingerprint exists to catch, and neither happened. Prior 69e9182ae205183ade24c3d8ad5d7292aea677144b1cbe443dd631bc25b0cafe -> 4ee15e742a9839671a900df4f57c1c91196c64256c8cab2ac445bec605a092d5. Both timing gates passed unchanged while this was red (scaling_ratio 0.783 vs budget 1.8, elapsed_ms_large 229ms vs the 1000ms backstop), so no throughput claim is being rebaselined away.",
"_rebaselined_3040_convex_endpoint_factory": "Const and Function gained a trailing convexEndpointFactory column. A deterministic 2,400-entity emit produced the same 35 CSV files and fingerprint c4d799c5336d616955b3530ba051b7dca300d1a0e412a66741cf2f27e04c533e. Removing the new Const and Function header fields plus the new trailing empty Function cell from each of 4,800 Function rows restored the exact prior fingerprint 4ee15e742a9839671a900df4f57c1c91196c64256c8cab2ac445bec605a092d5. No file or row moved or reordered. The measured scaling ratio remained 0.826 against the 1.8 budget and elapsed_ms_large was 307.75ms against the 1000ms backstop.",
"_rebaselined_3107_route_runtime_evidence": "Route gained trailing runtimeConfirmed BOOLEAN, runtimeSource STRING, and runtimeStatus STRING columns in its schema, CSV header/rows, COPY statement, and graph API projection. The deterministic emit still produces the same 35 CSV files; the synthetic benchmark graph has no Route rows, so the only byte drift is the Route CSV header and no row moved or reordered. Prior c4d799c5336d616955b3530ba051b7dca300d1a0e412a66741cf2f27e04c533e -> 7b2ec01a110dcbc66868fba2c97714aaece8c3864c2eba00df68b5c027f034d2. While the guard was red, scaling_ratio was 1.044 against the 1.8 budget and elapsed_ms_large was 121.08ms against the 1000ms backstop.",
"_note": "fingerprint = sha256 over per-file digests (filename + sha256(file bytes)), entry list sorted — binds each emitted line to its file so a row routed to the WRONG pair file changes the hash, AND catches within-file row reordering (file bytes hashed as-written). Byte-identity gate for #2203 U2/U3. NOTE: a future change that legitimately reorders emit (without changing the node/edge SET) will trip --check; regenerate then, and record WHY in a `_rebaselined_<reason>` key alongside — bench/scope-capture/baselines.json sets that convention and it is what makes a regenerated hash reviewable. scaling_budget bounds (t_large/t_small)/(LARGE/SMALL): observed ~0.95-1.05 (linear); 1.8 tolerates disk-I/O timing noise on CI while still catching an O(n^2) re-regression (~4x). max_ms_large=1000ms is a coarse absolute backstop (observed ~200ms) that catches a gross uniform slowdown the ratio gate misses; generous so CI host noise won't flake it. Regenerate via `node --import tsx bench/emit-persistence/measure.mjs`."
}

File diff suppressed because one or more lines are too long

View file

@ -399,12 +399,9 @@
* per parsed file, O(files) with no depth term, and the count gate in
* import-target-index-reuse.contract.test.ts is what holds it to one build
* per pass;
* - PHP's leg is measured with NO composer.json `resolutionConfig` is
* undefined here, as it always has been so `namespaceDirectories` only
* ever returns the directory of an already-resolved file and the PSR-4
* mapping branch stays unreached, exactly as `csharp` cannot reach the
* csproj leg. Closing that is a second PHP arm on the `csharp_csproj`
* precedent, not a parameter;
* - PHP runs with the Composer PSR-4 configuration every production project
* supplies. Configured hits and unmatched dependency misses share one
* workload, so the Composer gate cannot become an unmeasured fast path;
* - the `const` tail of PHP's leg (`candidateFiles.length === 1`) is a
* different ANSWER, not a different cost: `function` runs the identical
* candidate gather and `localDefs` filter and diverges only in the last two
@ -550,13 +547,10 @@ const HEAP_LARGE = 32000;
const HEAP_PAD = 8;
/** The languages whose retained per-pass index carries a BUDGET a ceiling, a
* floor derived from `heap_reading_bytes`, and the linear-growth ratio arm.
* All eight are measured the same way as the other nine (`retainedPassBytes`,
* one real import through the real resolver); what this list decides is which
* GATE a reading gets, not whether it is taken. The first five reach the shared
* `WorkspaceFileIndex` and retained NOTHING at BASE; `csharp_csproj` is the
* same corpus through the same index under the csproj context, and it is here
* rather than excluded as a duplicate because after #2903 its READ PATTERN,
* not its corpus, decides the number.
* All arms are measured through `retainedPassBytes`, one real import through
* the real resolver; this list decides which GATE a reading gets, not whether
* it is taken. The configured C# arm stays here because its read pattern
* reaches retained structures that the unconfigured arm cannot observe.
*
* The remaining three are `HEAP_BOUNDED`, DERIVED from this list rather than
* written beside it, and they carry an upper bound and NO floor. That asymmetry
@ -565,7 +559,7 @@ const HEAP_PAD = 8;
* would gate the noise. rust reads 16 B at both scales; swift's ratio is 0.888
* and cobol's 1.082, both outside the linearity every budgeted arm shows, so a
* floor and a ratio arm would be measuring the measurement. See the MEMORY
* section of the header for what re-measuring all seventeen found. */
* section of the header for what re-measuring the full inventory found. */
const HEAP_BUDGETED = [
'csharp',
'csharp_csproj',
@ -601,7 +595,7 @@ const HEAP_BUDGETED = [
/**
* The arms handed the fifth `context` argument `{ parsedFiles, parsedImport }`
* because their registered hook DECLARES it. Four of seventeen, and the
* because their registered hook DECLARES it. Four of seventeen arms, and the
* inventory arm at the foot of this file reconciles that claim against
* `SCOPE_RESOLVERS` in both directions rather than trusting this line.
*
@ -714,6 +708,15 @@ const joinBase = (baseUrl, rest) => (baseUrl === '' ? rest : `${baseUrl}/${rest}
*/
const tsBaseUrlFor = (pad) =>
pad === 0 ? '' : Array.from({ length: pad }, (_, n) => `d${n}`).join('/');
const phpComposerConfigFor = (pad) => ({
psr4: new Map([['App', joinBase(tsBaseUrlFor(pad), 'src/App')]]),
authoritativePsr4: new Set(['App']),
});
const renderPhpComposerConfig = (config) =>
[...config.psr4]
.map(([namespace, directory]) => `${namespace || '<root>'}=${directory || '<root>'}`)
.sort()
.join(';');
/** Keyed by LAYOUT name, so there is no `csharp_csproj` row: `buildFiles`
* aliases that arm to `csharp` before this table is read. */
const EXTENSION = {
@ -919,7 +922,7 @@ function collideDir(lang, d, i) {
`mod${d}/src/main/kotlin/com/example/models/inner/com/example/models`
: `mod${d}/src/main/kotlin/com/example/models`;
}
if (lang === 'php') return `svc${d}/src/Models`;
if (lang === 'php') return `src/App/Svc${d}/Models`;
if (lang === 'java') {
return d % 7 === 0
? `svc${d}/src/main/java/com/example/model/inner/model`
@ -1035,6 +1038,12 @@ function buildFiles(lang, fileCount, pad, shape) {
: ext;
files.push(`${prefix}${dir}/${stem}${suffix}`);
}
// One real suffix decoy makes the PHP external gate observable: with the
// gate, Vendor0 stays unresolved; without it, suffix fallback resolves this
// path and the exact fingerprint/external-probe result changes.
if (layout === 'php' && files.length > 0) {
files[files.length - 1] = `${prefix}legacy/Vendor0/Ghost/Missing.php`;
}
return files;
}
@ -1145,9 +1154,8 @@ function kotlinBenchmarkPackage(filePath) {
* The owner segment is the file's own directory name (`Ns7`, `Models`, `pkg7`),
* which is stable across the `small`, `deep` and `collide` arms so the `deep`
* arm differs from `small` in path DEPTH alone, exactly as it does for the path
* set. That matters here: `directoryAliases` emits one entry per path segment,
* so `filesByDirectory` is O(files × depth) and the depth arm is the only one
* that can see it.
* set. `filesByDirectory` is exact and linear in the file count; the shared
* suffix index remains the path-depth-sensitive structure this arm measures.
*/
function buildParsedFiles(lang, files) {
const parsedFiles = [];
@ -1247,21 +1255,18 @@ function uniqueTarget(lang, { local, r, d, j, dirs }) {
: `com.ghost${(r >>> 4) % 97}.deep.Missing`;
}
if (lang === 'php') {
// Backslash-separated, the way a `use` statement is actually written; the
// resolver normalizes them. No composer.json is threaded (the adapter's
// `resolutionConfig` is left undefined), so every one of these lands on
// `suffixResolve` — the leg that ran one `findIndex` over every file per
// path part per extension, ~50 of them, and measured 96.40 ms per import at
// 20k files before #2901.
return local
? `App\\Ns${d}\\File${j}`
: (r >>> 3) % 2 === 0
? [
'Psr\\Log\\LoggerInterface',
'Symfony\\Component\\Console\\Command',
'Doctrine\\ORM\\EntityManager',
][(r >>> 4) % 3]
: `Vendor${(r >>> 4) % 97}\\Ghost\\Missing`;
if (local) {
const namespace = d % 7 === 0 ? `Ns${d}\\Sub\\Ns${d}` : `Ns${d}`;
const leadingSeparator = (r >>> 3) % 4 === 0 ? '\\' : '';
return `${leadingSeparator}App\\${namespace}\\File${j}`;
}
return (r >>> 3) % 2 === 0
? [
'Psr\\Log\\LoggerInterface',
'Symfony\\Component\\Console\\Command',
'Doctrine\\ORM\\EntityManager',
][(r >>> 4) % 3]
: `Vendor${(r >>> 4) % 97}\\Ghost\\Missing`;
}
if (lang === 'java') {
// Java has NO in-repo-namespace gate (#2910 is filed for it), so a JDK
@ -1451,21 +1456,17 @@ function collideTarget(lang, { local, r, d, j, dirs }) {
: `com.ghost${(r >>> 4) % 97}.deep.Missing`;
}
if (lang === 'php') {
// `Models\Mod{n}` is carried by every service, so the segment-suffix key it
// resolves through holds one entry no matter how many files exist: PHP
// answers from keyed maps and is collision-IMMUNE, which is what this arm
// asserts. The local spelling still always resolves, as it does on the
// unique layout — PHP's cascade strips leading segments, so even the
// nested-same-name slice is reachable by a shorter suffix.
return local
? `App\\Models\\Mod${Math.floor(j / dirs)}`
: (r >>> 3) % 2 === 0
? [
'Psr\\Log\\LoggerInterface',
'Symfony\\Component\\Console\\Command',
'Doctrine\\ORM\\EntityManager',
][(r >>> 4) % 3]
: `Vendor${(r >>> 4) % 97}\\Ghost\\Missing`;
if (local) {
const leadingSeparator = (r >>> 3) % 4 === 0 ? '\\' : '';
return `${leadingSeparator}App\\Svc${j % dirs}\\Models\\Mod${Math.floor(j / dirs)}`;
}
return (r >>> 3) % 2 === 0
? [
'Psr\\Log\\LoggerInterface',
'Symfony\\Component\\Console\\Command',
'Doctrine\\ORM\\EntityManager',
][(r >>> 4) % 3]
: `Vendor${(r >>> 4) % 97}\\Ghost\\Missing`;
}
if (lang === 'java') {
// Every file declares the same package despite living under different
@ -1607,6 +1608,9 @@ function buildRepo(lang, fileCount, pad = 0, shape = 'unique') {
imports.push([from, mintTarget(lang, { local, r, d, j, dirs })]);
}
}
if (lang === 'php' && imports.length > 0) {
imports[0] = [files[0], 'Vendor0\\Ghost\\Missing'];
}
return { files, imports };
}
@ -1667,7 +1671,7 @@ function newPass(lang, files, pad = 0) {
restoreBenchmarkSideChannels(lang, parsedFiles);
return {
allFilePaths: new Set(parsedFiles.map((f) => f.filePath)),
config: undefined,
config: lang === 'php' ? phpComposerConfigFor(pad) : undefined,
parsedFiles,
};
}
@ -2039,7 +2043,9 @@ const HEAP_PROBE_TARGET = {
// (`getFilesInDir`) before answering null — the three-map read pattern.
csharp_csproj: 'App.Missing0',
ruby: 'gem0/missing/thing',
php: 'Vendor0\\Ghost\\Missing',
// A mapped-but-missing class forces the Composer mapping and suffix-index
// read paths. The separate external probe below keeps the fast gate visible.
php: 'App\\HeapGhost0\\AbsentHeapProbe',
java: 'com.google.common.vendor0.Missing',
javascript: 'vendor0/lib/missing',
python: 'vendor0.deep.missing',
@ -2107,11 +2113,24 @@ function measureHeap(lang) {
GC();
GC();
const probe = HEAP_PROBE_TARGET[lang];
const read = (files) => retainedPassBytes(lang, files, probe);
const read = (files) => retainedPassBytes(lang, files, probe, lang === 'php' ? HEAP_PAD : 0);
const small = flatten(buildFiles(lang, HEAP_SMALL, HEAP_PAD, 'unique'));
const bytesSmall = read(small);
const large = flatten(buildFiles(lang, HEAP_LARGE, HEAP_PAD, 'unique'));
const bytesLarge = read(large);
const phpGateShape =
lang === 'php'
? (() => {
const externalProbe = 'Vendor0\\Ghost\\Missing';
const config = phpComposerConfigFor(HEAP_PAD);
const pass = newPass(lang, large, HEAP_PAD);
return {
resolution_config: renderPhpComposerConfig(config),
external_probe: externalProbe,
external_result: renderResolved(resolveOne(lang, large[0], externalProbe, pass)),
};
})()
: {};
return {
files_small: HEAP_SMALL,
files_large: HEAP_LARGE,
@ -2121,6 +2140,7 @@ function measureHeap(lang) {
bytes_large: bytesLarge,
mib_large: Number((bytesLarge / 1024 / 1024).toFixed(2)),
ratio: Number((bytesLarge / bytesSmall / (HEAP_LARGE / HEAP_SMALL)).toFixed(3)),
...phpGateShape,
};
}
@ -2213,10 +2233,11 @@ const CONTEXT_PROBE = {
function measureContext(lang) {
const { from, target, parsedFiles } = CONTEXT_PROBE[lang];
const allFilePaths = new Set(parsedFiles.map((f) => f.filePath));
const config = lang === 'php' ? phpComposerConfigFor(0) : undefined;
const answer = (files) => {
restoreBenchmarkSideChannels(lang, files ?? []);
return renderResolved(
resolveOne(lang, from, target, { allFilePaths, config: undefined, parsedFiles: files }),
resolveOne(lang, from, target, { allFilePaths, config, parsedFiles: files }),
);
};
return {
@ -2249,11 +2270,11 @@ if (CHECK && GC === null) {
/**
* Every arm, and the registered language each one exercises.
*
* This used to be a hand-written list of seventeen strings under a comment
* This used to be a hand-written list of language strings under a comment
* claiming it was "every language in `SCOPE_RESOLVERS`" a claim nothing in
* the file could check, because the file never imported the registry. Adding a
* resolver to `pipeline/registry.ts` is two lines, neither of which is this
* one, so a seventeenth registered language would have shipped ungated and
* one, so a newly registered language would have shipped ungated and
* printed PASS. That is not a hypothetical failure mode: JavaScript reached
* `suffixResolve` with no index at all and measured 25 972 µs per import at
* 8000 files (PR #2911) for exactly as long as nothing gated it.
@ -2265,10 +2286,9 @@ if (CHECK && GC === null) {
* uses ten files away, and the same "one row per language" table
* `bench/cfg/measure.mjs` keeps.
*
* The mapping is many-to-one on purpose: `csharp` and `csharp_csproj` are two
* arms over one registered resolver, differing only in whether `csharpConfigs`
* is supplied, because the no-csproj arm returns before it can reach the leg
* #2902 indexed.
* The mapping is many-to-one only for C#: the configured arm reaches the
* csproj branch that the default arm cannot observe. PHP's sole arm carries
* its production Composer configuration directly.
*/
const LANG_REGISTRY = {
go: SupportedLanguages.Go,
@ -2457,7 +2477,7 @@ const SCALE_SHAPE = {
'one of them alone moves nothing in the others.',
};
/** The same, for the heap arm the four inputs that decide what it measures.
* Asserted for all seventeen, budgeted tier and bounded tier alike, and it is
* Asserted for all seventeen arms, budgeted tier and bounded tier alike, and it is
* the bounded tier that needs it most: a bound is a single comparison, so a
* probe swapped for one that reaches less is a bound over a smaller workload
* and there is no floor beside it to notice.
@ -2474,6 +2494,13 @@ const HEAP_SHAPE = {
'ceiling, floor, bound and ratio passing over an arm that changed workload. Deterministic: ' +
'a re-run will not change it.',
};
const PHP_HEAP_SHAPE = {
fields: [...HEAP_SHAPE.fields, 'resolution_config', 'external_probe', 'external_result'],
why:
HEAP_SHAPE.why +
' PHP also pins the Composer mapping and a suffix-matchable external decoy so the mapped ' +
'index path and the external fast gate remain separate observable arms.',
};
/** The same, for the `context` arm. All three fields are exact strings, not
* bounds: this arm has no measurement noise at all it resolves one import
* two ways over a three-file corpus so anything less than equality would be
@ -2496,7 +2523,7 @@ const CONTEXT_SHAPE = {
* a fifth parameter. */
const armShapes = (lang) => [
...SCALES.map((scale) => [scale, SCALE_SHAPE]),
['heap', HEAP_SHAPE],
['heap', lang === 'php' ? PHP_HEAP_SHAPE : HEAP_SHAPE],
...(CONTEXT_LANGS.includes(lang) ? [['context', CONTEXT_SHAPE]] : []),
];

View file

@ -0,0 +1,8 @@
{
"_comment": "Baselines for bench/java-lombok-synthesis/measure.mjs --check (#2885). fingerprint is sha256 over synthetic Method node ids on the lombok_large corpus (800 @Data entities × 4 fields × 2 accessors = 6400 methods). no_lombok arm must emit 0 methods. Budgets are timing gates with CI headroom.",
"fingerprint": "b935d6894d32de7594d5887bb62af6ade2b66b19d6846700a05ef3baf1ed1eb1",
"scaling_budget": 1.6,
"_scaling_note": "(t_large/t_small)/(800/250) on the lombok arm. Measured ~1.01.",
"widening_overhead_budget": 2.5,
"_widening_overhead_note": "lombok_large_ms / no_lombok_large_ms using an unannotated, shape-equivalent four-field control. Measured about 1.24; budget guards against a pathological feature-arm regression."
}

View file

@ -0,0 +1,124 @@
/**
* Build-free throughput + identity bench for Java Lombok accessor synthesis.
*
* Arms:
* - no_lombok: unannotated fields (shape-equivalent control) synthesizer no-ops
* - lombok_heavy: @Data classes (feature path)
*
* Times synthesizeLombokAccessors over N separate files (not one giant buffer).
*
* Usage:
* node --import tsx bench/java-lombok-synthesis/measure.mjs
* node --import tsx bench/java-lombok-synthesis/measure.mjs --check
*/
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import Parser from 'tree-sitter';
import Java from 'tree-sitter-java';
import { synthesizeLombokAccessors } from '../../src/core/ingestion/languages/java/lombok-synthesizer.ts';
import {
fingerprintIds,
minSample,
runBaselineCheck,
runMethodCountCheck,
} from '../lib/identity-guard.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const BASELINE_PATH = path.resolve(__dirname, 'baselines.json');
const SMALL = 250;
const LARGE = 800;
const REPS = 15;
const WARMUP = 5;
function entitySource(i, mode) {
if (mode === 'lombok') {
return `import lombok.Data;
@Data
public class Entity${i} {
private String id;
private String name;
private boolean active;
private Long amount;
}
`;
}
return `public class Entity${i} {
private String id;
private String name;
private boolean active;
private Long amount;
}
`;
}
function ownerMap(tree, filePath) {
const map = new Map();
const walk = (node) => {
if (node.type === 'class_declaration') {
const name = node.childForFieldName('name')?.text;
if (name) map.set(node.id, `Class:${filePath}:${name}`);
}
for (const c of node.children) walk(c);
};
walk(tree.rootNode);
return map;
}
function prepare(mode, fileCount) {
const files = [];
for (let i = 0; i < fileCount; i++) {
const parser = new Parser();
parser.setLanguage(Java);
const filePath = `bench/${mode}/Entity${i}.java`;
const tree = parser.parse(entitySource(i, mode));
files.push({ tree, filePath, owners: ownerMap(tree, filePath) });
}
return files;
}
function runAll(files) {
const nodes = [];
for (const f of files) {
const result = synthesizeLombokAccessors(f.tree, f.filePath, f.owners);
for (const n of result.nodes) nodes.push(n.id);
}
return nodes;
}
function measure(mode, fileCount) {
const files = prepare(mode, fileCount);
const { last, ms } = minSample(() => runAll(files), WARMUP, REPS);
return {
files: fileCount,
ms,
methods: last.length,
fingerprint: fingerprintIds(last),
};
}
const report = {
no_lombok_small: measure('bare', SMALL),
no_lombok_large: measure('bare', LARGE),
lombok_small: measure('lombok', SMALL),
lombok_large: measure('lombok', LARGE),
};
report.scaling_ratio = Number(
(report.lombok_large.ms / report.lombok_small.ms / (LARGE / SMALL)).toFixed(3),
);
report.widening_overhead = Number(
(report.lombok_large.ms / Math.max(report.no_lombok_large.ms, 0.001)).toFixed(3),
);
report.fingerprint = report.lombok_large.fingerprint;
runMethodCountCheck(report, {
no_lombok_large: 0,
lombok_large: 6400,
});
if (!process.argv.includes('--check')) {
console.log(JSON.stringify(report, null, 2));
process.exit(0);
}
runBaselineCheck(report, BASELINE_PATH);

View file

@ -0,0 +1,8 @@
{
"_comment": "Baselines for bench/java-wildcard-route-constants/measure.mjs --check (#3110). fingerprint is sha256 over 800 materialized route bindings from 800 constant files and must match the named-import control. The benchmark builds the constant import index once per repo pass, matching ingestion and group wiring.",
"fingerprint": "8114e613e93ce0ef6220b810850888592e0e822fe04bf2eb5d8fc4ec3dbba5ef",
"scaling_budget": 1.6,
"_scaling_note": "(t_large/t_small)/(800/250) while both constant files and wildcard importers scale. Measured about 1.14 with the suffix index; repeated candidate scans are quadratic.",
"absolute_ms_budget": 10,
"_absolute_ms_note": "Wildcard materialization for 800 controllers. Measured about 1.1 ms; the generous ceiling catches gross regressions without treating the near-zero named-import control as a stable ratio denominator."
}

View file

@ -0,0 +1,158 @@
/**
* Build-free throughput + identity benchmark for Java wildcard-static route constants.
*
* Arms:
* - named: explicit `import static ...ApiPaths.ROUTE_n` control
* - wildcard: `import static ...ApiPaths.*` feature path
*
* Parsing is prepared outside the timer. The measured path mirrors ingestion:
* build the constant-key index once, materialize pending wildcard imports, then
* read the resulting binding. Route folding itself has separate integration
* coverage and an older per-fold index cost shared by both arms.
*
* Usage:
* node --import tsx bench/java-wildcard-route-constants/measure.mjs
* node --import tsx bench/java-wildcard-route-constants/measure.mjs --check
*/
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import Parser from 'tree-sitter';
import Java from 'tree-sitter-java';
import {
extractJavaModuleConstants,
prepareJavaRouteConstants,
} from '../../src/core/ingestion/route-extractors/java-const-resolver.ts';
import {
fingerprintIds,
minSampleFresh,
runBaselineCheck,
runCountCheck,
runFingerprintParityCheck,
} from '../lib/route-constant-guard.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const BASELINE_PATH = path.resolve(__dirname, 'baselines.json');
const SMALL = 250;
const LARGE = 800;
const REPS = 15;
const WARMUP = 5;
const parser = new Parser();
parser.setLanguage(Java);
function constantsSource(i) {
return `package bench.constants;
public final class ApiPaths${i} {
public static final String ROUTE = "/api/routes/${i}";
}
`;
}
function controllerSource(i, mode) {
const fqn = `bench.constants.ApiPaths${i}`;
const imported = mode === 'wildcard' ? `import static ${fqn}.*;` : `import static ${fqn}.ROUTE;`;
return `package bench.web;
${imported}
class Controller${i} {}
`;
}
function cloneConstants(mc) {
return {
literals: new Map(mc.literals),
exprs: new Map(mc.exprs),
imports: new Map(mc.imports),
wildcardImports: mc.wildcardImports ? [...mc.wildcardImports] : undefined,
unfoldableDeclarations: new Set(mc.unfoldableDeclarations ?? []),
};
}
function prepare(mode, fileCount) {
const constants = [];
const controllers = [];
for (let i = 0; i < fileCount; i++) {
constants.push({
key: `bench/constants/ApiPaths${i}.java`,
constants: extractJavaModuleConstants(parser.parse(constantsSource(i))),
});
controllers.push({
key: `bench/web/Controller${i}.java`,
route: 'ROUTE',
constants: extractJavaModuleConstants(parser.parse(controllerSource(i, mode))),
});
}
return { constants, controllers };
}
function instantiate(prepared) {
const repo = new Map();
for (const constant of prepared.constants) {
repo.set(constant.key, cloneConstants(constant.constants));
}
const controllers = [];
for (const controller of prepared.controllers) {
repo.set(controller.key, cloneConstants(controller.constants));
controllers.push({ key: controller.key, route: controller.route });
}
return { repo, controllers };
}
function runAll(instance) {
const { repo, controllers } = instance;
prepareJavaRouteConstants(repo);
const bindings = [];
for (const controller of controllers) {
const mc = repo.get(controller.key);
const binding = mc.imports.get(controller.route);
if (binding) {
bindings.push(
`${controller.key}:${controller.route}:${binding.module}:${binding.originalName}`,
);
}
}
return bindings;
}
function measure(mode, fileCount) {
const prepared = prepare(mode, fileCount);
// Expansion mutates each importing file's `imports` map. Give every timed
// sample a fresh repo, but build those clones outside the timer.
const { last, ms } = minSampleFresh(() => instantiate(prepared), runAll, WARMUP, REPS);
return {
files: fileCount,
ms,
bindings: last.length,
fingerprint: fingerprintIds(last),
};
}
const report = {
named_small: measure('named', SMALL),
named_large: measure('named', LARGE),
wildcard_small: measure('wildcard', SMALL),
wildcard_large: measure('wildcard', LARGE),
};
report.scaling_ratio = Number(
(report.wildcard_large.ms / report.wildcard_small.ms / (LARGE / SMALL)).toFixed(3),
);
report.overhead_us_per_binding = Number(
(
((report.wildcard_large.ms - report.named_large.ms) * 1000) /
report.wildcard_large.bindings
).toFixed(3),
);
report.absolute_ms = report.wildcard_large.ms;
report.fingerprint = report.wildcard_large.fingerprint;
runCountCheck(report, 'bindings', {
named_large: LARGE,
wildcard_large: LARGE,
});
runFingerprintParityCheck(report, 'named_large', 'wildcard_large');
if (!process.argv.includes('--check')) {
console.log(JSON.stringify(report, null, 2));
process.exit(0);
}
runBaselineCheck(report, BASELINE_PATH);

View file

@ -0,0 +1,8 @@
{
"_comment": "Baselines for bench/kotlin-jvm-accessors/measure.mjs --check (#2885). fingerprint is sha256 over synthetic Method node ids on the data_large corpus (800 data classes × 4 vars × 2 accessors = 6400 methods). no_props arm uses @JvmField so kotlinc and the synthesizer emit 0 accessor methods. Budgets are timing gates with CI headroom.",
"fingerprint": "18e4f295a437a747c486699e8ec5d310d9bde54437d9a96356a1b1bf8442b0ef",
"scaling_budget": 1.6,
"_scaling_note": "(t_large/t_small)/(800/250) on the data-class arm. Measured ~1.02.",
"widening_overhead_budget": 2.5,
"_widening_overhead_note": "data_large_ms / no_props_large_ms. The @JvmField control preserves four property declarations without accessors; budget guards against a pathological synthesis-arm regression."
}

View file

@ -0,0 +1,121 @@
/**
* Build-free throughput + identity bench for Kotlin JVM accessor synthesis.
*
* Arms:
* - no_props: @JvmField properties with no JVM accessors (control)
* - data_class: data class constructor properties (feature path)
*
* Usage:
* node --import tsx bench/kotlin-jvm-accessors/measure.mjs
* node --import tsx bench/kotlin-jvm-accessors/measure.mjs --check
*/
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import Parser from 'tree-sitter';
import { SupportedLanguages } from 'gitnexus-shared';
import { getLanguageGrammar } from '../../src/core/tree-sitter/parser-loader.ts';
import { synthesizeLombokAccessors } from '../../src/core/ingestion/languages/kotlin/lombok-synthesizer.ts';
import {
fingerprintIds,
minSample,
runBaselineCheck,
runMethodCountCheck,
} from '../lib/identity-guard.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const BASELINE_PATH = path.resolve(__dirname, 'baselines.json');
const SMALL = 250;
const LARGE = 800;
const REPS = 15;
const WARMUP = 5;
function entitySource(i, mode) {
if (mode === 'data') {
return `data class Entity${i}(var id: String, var name: String, var active: Boolean, var amount: Long)
`;
}
// @JvmField suppresses accessors in kotlinc and in the synthesizer while
// retaining the same four property declarations as the feature arm.
return `class Entity${i} {
@JvmField var id: String = ""
@JvmField var name: String = ""
@JvmField var active: Boolean = false
@JvmField var amount: Long = 0
}
`;
}
function ownerMap(tree, filePath) {
const map = new Map();
const walk = (node) => {
if (node.type === 'class_declaration' || node.type === 'object_declaration') {
const name =
node.childForFieldName('name')?.text ??
node.namedChildren.find((c) => c.type === 'type_identifier')?.text;
if (name) map.set(node.id, `Class:${filePath}:${name}`);
}
for (const c of node.children) walk(c);
};
walk(tree.rootNode);
return map;
}
function prepare(mode, fileCount) {
const files = [];
const lang = getLanguageGrammar(SupportedLanguages.Kotlin);
for (let i = 0; i < fileCount; i++) {
const parser = new Parser();
parser.setLanguage(lang);
const filePath = `bench/${mode}/Entity${i}.kt`;
const tree = parser.parse(entitySource(i, mode));
files.push({ tree, filePath, owners: ownerMap(tree, filePath), parser });
}
return files;
}
function runAll(files) {
const nodes = [];
for (const f of files) {
const result = synthesizeLombokAccessors(f.tree, f.filePath, f.owners);
for (const n of result.nodes) nodes.push(n.id);
}
return nodes;
}
function measure(mode, fileCount) {
const files = prepare(mode, fileCount);
const { last, ms } = minSample(() => runAll(files), WARMUP, REPS);
return {
files: fileCount,
ms,
methods: last.length,
fingerprint: fingerprintIds(last),
};
}
const report = {
no_props_small: measure('hand', SMALL),
no_props_large: measure('hand', LARGE),
data_small: measure('data', SMALL),
data_large: measure('data', LARGE),
};
report.scaling_ratio = Number(
(report.data_large.ms / report.data_small.ms / (LARGE / SMALL)).toFixed(3),
);
report.widening_overhead = Number(
(report.data_large.ms / Math.max(report.no_props_large.ms, 0.001)).toFixed(3),
);
report.fingerprint = report.data_large.fingerprint;
runMethodCountCheck(report, {
no_props_large: 0,
data_large: 6400,
});
if (!process.argv.includes('--check')) {
console.log(JSON.stringify(report, null, 2));
process.exit(0);
}
runBaselineCheck(report, BASELINE_PATH);

View file

@ -0,0 +1,10 @@
{
"_comment": "Baselines for bench/kotlin-star-route-constants/measure.mjs --check (#3110). fingerprint is sha256 over 800 folded route facts from 800 constant files and must match the explicit-import control. The feature arm resolves package-star names through one prepared KotlinConstantIndex.",
"fingerprint": "881101236c511d73d3894d3c9bd2e4a166e3437329149dd99fd16c256435482e",
"scaling_budget": 1.6,
"_scaling_note": "(t_large/t_small)/(800/250) while both constant files and importing controllers scale. Measured about 1.07-1.14.",
"widening_overhead_budget": 2.5,
"_widening_overhead_note": "star_large_ms / named_large_ms. Measured below 1.0; budget guards against a pathological star-lookup regression.",
"absolute_ms_budget": 5,
"_absolute_ms_note": "Package-star folding for 800 controllers. Measured below 0.6 ms; budget includes substantial CI headroom."
}

View file

@ -0,0 +1,156 @@
/**
* Build-free throughput + identity benchmark for Kotlin package-star route constants.
*
* Arms:
* - named: explicit `import bench.constants.ROUTE_n` control
* - star: `import bench.constants.*` feature path
*
* Parsing is prepared outside the timer. The measured path mirrors the Kotlin
* group plugin: overlay one importing controller on the prepared constant
* index, then fold its route.
*
* Usage:
* node --import tsx bench/kotlin-star-route-constants/measure.mjs
* node --import tsx bench/kotlin-star-route-constants/measure.mjs --check
*/
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import Parser from 'tree-sitter';
import { requireVendoredGrammar } from '../../src/core/tree-sitter/vendored-grammars.ts';
import {
buildKotlinConstantIndex,
extractKotlinModuleConstants,
foldKotlinOperands,
overlayKotlinConstantIndex,
} from '../../src/core/ingestion/route-extractors/kotlin-const-resolver.ts';
import {
fingerprintIds,
minSampleFresh,
runBaselineCheck,
runCountCheck,
runFingerprintParityCheck,
} from '../lib/route-constant-guard.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const BASELINE_PATH = path.resolve(__dirname, 'baselines.json');
const SMALL = 250;
const LARGE = 800;
const REPS = 15;
const WARMUP = 5;
const parser = new Parser();
parser.setLanguage(requireVendoredGrammar('tree-sitter-kotlin'));
function constantsSource(i) {
return `package bench.constants
const val ROUTE_${i} = "/api/routes/${i}"
`;
}
function controllerSource(i, mode) {
const route = `ROUTE_${i}`;
const imported = mode === 'star' ? 'import bench.constants.*' : `import bench.constants.${route}`;
return `package bench.web
${imported}
class Controller${i}
`;
}
function cloneConstants(mc) {
return {
literals: new Map(mc.literals),
exprs: new Map(mc.exprs),
imports: new Map(mc.imports),
wildcardImports: mc.wildcardImports ? [...mc.wildcardImports] : undefined,
packageName: mc.packageName,
unfoldableDeclarations: new Set(mc.unfoldableDeclarations),
topLevelDeclarations: new Set(mc.topLevelDeclarations),
};
}
function prepare(mode, fileCount) {
const constants = [];
const controllers = [];
for (let i = 0; i < fileCount; i++) {
constants.push({
key: `bench/constants/ApiPaths${i}.kt`,
constants: extractKotlinModuleConstants(parser.parse(constantsSource(i))),
});
controllers.push({
key: `bench/web/Controller${i}.kt`,
route: `ROUTE_${i}`,
constants: extractKotlinModuleConstants(parser.parse(controllerSource(i, mode))),
});
}
return { constants, controllers };
}
function instantiate(prepared) {
const baseRepo = new Map();
for (const constant of prepared.constants) {
baseRepo.set(constant.key, cloneConstants(constant.constants));
}
const controllers = prepared.controllers.map((controller) => ({
key: controller.key,
route: controller.route,
constants: cloneConstants(controller.constants),
}));
return { baseRepo, controllers };
}
function runAll(instance) {
const { baseRepo, controllers } = instance;
const baseIndex = buildKotlinConstantIndex(baseRepo);
const routes = [];
for (const controller of controllers) {
const index = overlayKotlinConstantIndex(baseIndex, controller.key, controller.constants);
const route = foldKotlinOperands(
controller.key,
[{ kind: 'ref', name: controller.route }],
index.repo,
[],
index,
);
if (route !== null) routes.push(`${controller.key}:${route}`);
}
return routes;
}
function measure(mode, fileCount) {
const prepared = prepare(mode, fileCount);
const { last, ms } = minSampleFresh(() => instantiate(prepared), runAll, WARMUP, REPS);
return {
files: fileCount,
ms,
routes: last.length,
fingerprint: fingerprintIds(last),
};
}
const report = {
named_small: measure('named', SMALL),
named_large: measure('named', LARGE),
star_small: measure('star', SMALL),
star_large: measure('star', LARGE),
};
report.scaling_ratio = Number(
(report.star_large.ms / report.star_small.ms / (LARGE / SMALL)).toFixed(3),
);
report.widening_overhead = Number(
(report.star_large.ms / Math.max(report.named_large.ms, 0.001)).toFixed(3),
);
report.absolute_ms = report.star_large.ms;
report.fingerprint = report.star_large.fingerprint;
runCountCheck(report, 'routes', {
named_large: LARGE,
star_large: LARGE,
});
runFingerprintParityCheck(report, 'named_large', 'star_large');
if (!process.argv.includes('--check')) {
console.log(JSON.stringify(report, null, 2));
process.exit(0);
}
runBaselineCheck(report, BASELINE_PATH);

View file

@ -0,0 +1,62 @@
/**
* Shared fingerprint + --check for JVM accessor synthesis benches.
*/
import fs from 'node:fs';
import crypto from 'node:crypto';
export function fingerprintIds(ids) {
return crypto
.createHash('sha256')
.update([...ids].sort().join('\n'))
.digest('hex');
}
export function minSample(run, warmup, reps) {
for (let w = 0; w < warmup; w++) run();
const samples = [];
let last;
for (let r = 0; r < reps; r++) {
const t0 = performance.now();
last = run();
samples.push(performance.now() - t0);
}
return { last, ms: Math.min(...samples) };
}
export function runMethodCountCheck(report, expectedCounts) {
const errors = [];
for (const [arm, expected] of Object.entries(expectedCounts)) {
const actual = report[arm]?.methods;
if (actual !== expected) {
errors.push(`${arm}.methods ${String(actual)} != ${expected}`);
}
}
if (errors.length) {
console.error(JSON.stringify({ report, errors }, null, 2));
process.exit(1);
}
}
export function runBaselineCheck(report, baselinePath) {
const baseline = JSON.parse(fs.readFileSync(baselinePath, 'utf-8'));
const errors = [];
if (report.fingerprint !== baseline.fingerprint) {
errors.push(`fingerprint drift: ${report.fingerprint} != ${baseline.fingerprint}`);
}
if (report.scaling_ratio > baseline.scaling_budget) {
errors.push(`scaling_ratio ${report.scaling_ratio} > ${baseline.scaling_budget}`);
}
if (
baseline.widening_overhead_budget !== undefined &&
report.widening_overhead > baseline.widening_overhead_budget
) {
errors.push(
`widening_overhead ${report.widening_overhead} > ${baseline.widening_overhead_budget}`,
);
}
if (errors.length) {
console.error(JSON.stringify({ report, errors }, null, 2));
process.exit(1);
}
console.log(JSON.stringify({ ok: true, report }, null, 2));
}

View file

@ -0,0 +1,77 @@
/** Shared fingerprint + --check helpers for route-constant benchmarks. */
import fs from 'node:fs';
import crypto from 'node:crypto';
export function fingerprintIds(ids) {
return crypto
.createHash('sha256')
.update([...ids].sort().join('\n'))
.digest('hex');
}
/** Min sample for mutating benchmarks that need fresh state per repetition. */
export function minSampleFresh(create, run, warmup, reps) {
for (let w = 0; w < warmup; w++) run(create());
const samples = [];
let last;
for (let r = 0; r < reps; r++) {
const state = create();
const t0 = performance.now();
last = run(state);
samples.push(performance.now() - t0);
}
return { last, ms: Math.min(...samples) };
}
export function runCountCheck(report, field, expectedCounts) {
const errors = [];
for (const [arm, expected] of Object.entries(expectedCounts)) {
const actual = report[arm]?.[field];
if (actual !== expected) {
errors.push(`${arm}.${field} ${String(actual)} != ${expected}`);
}
}
failIfNeeded(report, errors);
}
export function runFingerprintParityCheck(report, leftArm, rightArm) {
const left = report[leftArm]?.fingerprint;
const right = report[rightArm]?.fingerprint;
failIfNeeded(
report,
left === right ? [] : [`${leftArm}.fingerprint ${left} != ${rightArm}.fingerprint ${right}`],
);
}
export function runBaselineCheck(report, baselinePath) {
const baseline = JSON.parse(fs.readFileSync(baselinePath, 'utf-8'));
const errors = [];
if (report.fingerprint !== baseline.fingerprint) {
errors.push(`fingerprint drift: ${report.fingerprint} != ${baseline.fingerprint}`);
}
if (report.scaling_ratio > baseline.scaling_budget) {
errors.push(`scaling_ratio ${report.scaling_ratio} > ${baseline.scaling_budget}`);
}
if (
baseline.absolute_ms_budget !== undefined &&
report.absolute_ms > baseline.absolute_ms_budget
) {
errors.push(`absolute_ms ${report.absolute_ms} > ${baseline.absolute_ms_budget}`);
}
if (
baseline.widening_overhead_budget !== undefined &&
report.widening_overhead > baseline.widening_overhead_budget
) {
errors.push(
`widening_overhead ${report.widening_overhead} > ${baseline.widening_overhead_budget}`,
);
}
failIfNeeded(report, errors);
console.log(JSON.stringify({ ok: true, report }, null, 2));
}
function failIfNeeded(report, errors) {
if (errors.length === 0) return;
console.error(JSON.stringify({ report, errors }, null, 2));
process.exit(1);
}

View file

@ -212,8 +212,10 @@
"_rebaselined_blind_spots_2856": "#2856 blind-spots series: the JS/TS SCOPE queries gained capture rules, so fingerprint drift is expected and additive. Verified before re-baselining by diffing the capture-name sets in both scope queries against origin/main: TypeScript gained exactly @reference.read.identifier (A2 bare-identifier reads in value positions) and @reference.type (R2-2 type references, so a declared contract stops reporting incoming:{}); JavaScript gained exactly @reference.read.identifier, @reference.read.destructured (R2-1c) and @reference.write.property-key (R2-1b record-construction writes). NOTHING was removed on either side \u2014 the delta is a pure superset, which is the check that no existing capture moved. capture_groups_small/large are unchanged (4503/14403) because those measure the SYNTHETIC scaling source, which this branch does not touch; only the fixture-corpus count moves. capture_groups_fp 2097 -> 2338 and fixture_count 146 -> 151 from 21 new lang-resolution fixtures. Scaling stayed linear and inside budget: typescript 1.116 < 1.5, javascript 1.010 < 1.5. Prior typescript ed92588e0fc7b28b3a0174339ac378b4dd85965fe007db1208dea97a65ce0571 -> f66a3e6f1e096431e7046505129a627deaa00ca0de5bc846b080591b397248f7; prior javascript 806f70ad3cce5fc849f6d06a08ace8a95f92a1ea84a2418fddabb1eef5846594 -> 2026993b81b873839dd2ef8797d9c14d9c48516b2b57b05ac17d8d43f2f4eba3."
},
"kotlin": {
"fingerprint": "973d702510002dda76166e017c5eca90cae37a139f5a512877b7a1b04ad19dc5",
"fingerprint": "a9d3f0db7547ff47856159debf15d2a6f427efca97a6af27b4004174ed432132",
"scaling_budget": 1.5,
"_rebaselined_interface_abstract_2885": "#2885: Kotlin interface property accessors stay in the capture set (groups still 5753/18403 and capture_groups_fp 2563) but Method isAbstract is now true for body-less interface properties, which changes accessor-plan identity in the fixture digest. Prior 82ae5e1f750580383344d4c84c400a290474528cd502be4af8cd56705819a683 -> aeafc7a87402c933786ef582b7c98683b1822b78fa909e605cb97552867fa0d5; CI scaling 0.838 < 1.5.",
"_rebaselined_jvm_property_accessors_2885": "#2885: Kotlin val/var properties now emit JVM getter/setter scope and declaration captures, including data-class constructor properties and custom accessors. Synthetic scaling counts move 4753/15203 -> 5753/18403; fixture-corpus groups move 2367 -> 2563. Accessor declaration sidecars use the canonical @declaration.qualified_name key, preserve same-name owner identity, follow JvmAbi is-prefix naming, and suppress @JvmName-renamed accessors until their custom names are modeled. Prior f98e7e936afbce0e99588285cfc603bf945fd58c5de45271860509a5d90eb832 -> 82ae5e1f750580383344d4c84c400a290474528cd502be4af8cd56705819a683; scaling 0.869 < 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.",
"_added": "#1951: bench coverage added (was ungated); scale source heritage-bearing (: Base()); js/kotlin O(n^2) findNodeAtRange-per-match fixed to threaded captured node, now linear.",
@ -226,11 +228,12 @@
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior d3c4d2fa0d82d248a2299cfc888b067187ad1faf2c87a97f93c6ed835eefc3f1 -> c1f0cc9058ab11b7cd6fc8b440deb6db2b2f530f2eb21178923e68a3d0796c4b.",
"_rebaselined_2766_await_subscript_emission": "#2766: extractMixedChain now walks THROUGH await and subscript nodes and peels transparent wrappers at loop entry, so sites whose receiver is `repos[0]` or `(await f())` mint a receiver chain where they previously minted none. EMISSION CHANGE: more sites carry `@reference.receiver-chain`; no existing chain changed shape. Only go and kotlin drifted of 15 \u2014 the two whose fixture corpora contain such receivers. Prior c1f0cc9058ab11b7cd6fc8b440deb6db2b2f530f2eb21178923e68a3d0796c4b -> efd5dbf80ffcd3bab2834d1010f6fe2b239dcc5d58229938dea9cff8d0f380f2.",
"_rebaselined_2960_declared_package_fixture": "#2960 adds four Kotlin declared-package import-resolution fixture files. This is fixture-corpus growth only: fixture_count 137 -> 141 and capture_groups_fp 2334 -> 2367; the synthetic capture counts remain 4753/15203, no Kotlin scope-capture query or implementation changed, and package resolution runs after capture. Other language fingerprints matched their baselines in the same CI run. Prior a184f8ff0ae40d246db855b63f7ff26bda3afac03e5f4c76e4593c7e2cefce54 -> f98e7e936afbce0e99588285cfc603bf945fd58c5de45271860509a5d90eb832.",
"capture_groups_small": 4753,
"capture_groups_large": 15203,
"capture_groups_fp": 2367,
"capture_groups_small": 5753,
"capture_groups_large": 18403,
"capture_groups_fp": 2563,
"fixture_count": 141,
"_rebaselined_1432_member_call_callee_name": "#1432 (Zig): the shared callable-flow reader no longer names a callee by simple name for a MEMBER call (`@callable-flow.direct-callee-name` requires a direct designator: `f(x)`, `ns.f(x)`), and a member call is a field-stored-callable invoke only when a MEMBER store (`o.f = handler`) or a declared callable-typed field is visible - a same-named plain binding no longer gates it. CAPTURE-EMISSION CHANGE, not fixture growth (fixture_count unchanged). Only drift: `users.map { it.name }.forEach { name -> println(name) }` (kotlin-lambda-scopes/App.kt) loses `direct-callee-name|forEach` (member call). capture_groups_fp 2334 (unchanged). Prior a184f8ff0ae40d246db855b63f7ff26bda3afac03e5f4c76e4593c7e2cefce54 -> 5a181af0dbc9451937da0964c40d3f3f9820914ca429d873bb5c812b5e2b9284.",
"_rebaselined_1432_rebase_onto_2960": "#1432 rebase onto main @ aac7515d: the kotlin fingerprint is a COMBINATION of two independent changes, so neither side of the merge conflict was correct on its own and resolving it by picking a side would have committed a fingerprint no run can reproduce. main's #2960 added four declared-package fixture files (fixture_count 137 -> 141, capture_groups_fp 2334 -> 2367); this branch's `_rebaselined_1432_member_call_callee_name` drops `direct-callee-name|forEach` from one member call. Recomputed under both: capture_groups_fp 2367 and fixture_count 141 match main's committed counts EXACTLY (this branch's change is emission-only and moves no count), capture_groups_small/large stay 4753/15203 (the SYNTHETIC scaling source, which neither change touches), scaling 1.003 < 1.5, and the other 14 languages report ok against their committed baselines in the same run - which is the check that the rebase replayed nothing else into the capture stream. Attribution is exact rather than inferred: moving test/fixtures/lang-resolution/kotlin-import-package-evidence aside and re-running returns kotlin to 5a181af0dbc9451937da0964c40d3f3f9820914ca429d873bb5c812b5e2b9284 byte-for-byte with fixture_count back at 137 and capture_groups_fp back at 2334 - this branch's pre-rebase value - so the whole delta is #2960's corpus growth layered on top, with nothing else moving. Prior (this branch, pre-rebase) 5a181af0dbc9451937da0964c40d3f3f9820914ca429d873bb5c812b5e2b9284 and (main) f98e7e936afbce0e99588285cfc603bf945fd58c5de45271860509a5d90eb832 -> 973d702510002dda76166e017c5eca90cae37a139f5a512877b7a1b04ad19dc5."
"_rebaselined_1432_rebase_onto_2960": "#1432 rebase onto main @ aac7515d: the kotlin fingerprint is a COMBINATION of two independent changes, so neither side of the merge conflict was correct on its own and resolving it by picking a side would have committed a fingerprint no run can reproduce. main's #2960 added four declared-package fixture files (fixture_count 137 -> 141, capture_groups_fp 2334 -> 2367); this branch's `_rebaselined_1432_member_call_callee_name` drops `direct-callee-name|forEach` from one member call. Recomputed under both: capture_groups_fp 2367 and fixture_count 141 match main's committed counts EXACTLY (this branch's change is emission-only and moves no count), capture_groups_small/large stay 4753/15203 (the SYNTHETIC scaling source, which neither change touches), scaling 1.003 < 1.5, and the other 14 languages report ok against their committed baselines in the same run - which is the check that the rebase replayed nothing else into the capture stream. Attribution is exact rather than inferred: moving test/fixtures/lang-resolution/kotlin-import-package-evidence aside and re-running returns kotlin to 5a181af0dbc9451937da0964c40d3f3f9820914ca429d873bb5c812b5e2b9284 byte-for-byte with fixture_count back at 137 and capture_groups_fp back at 2334 - this branch's pre-rebase value - so the whole delta is #2960's corpus growth layered on top, with nothing else moving. Prior (this branch, pre-rebase) 5a181af0dbc9451937da0964c40d3f3f9820914ca429d873bb5c812b5e2b9284 and (main) f98e7e936afbce0e99588285cfc603bf945fd58c5de45271860509a5d90eb832 -> 973d702510002dda76166e017c5eca90cae37a139f5a512877b7a1b04ad19dc5.",
"_rebaselined_1432_merge_main_2885": "#1432 merge of main @ 212e007a: the kotlin fingerprint is again a COMBINATION of two independent changes \u2014 main's #2885 JVM property accessors / interface-abstract (4753/15203 -> 5753/18403, capture_groups_fp 2367 -> 2563) and this branch's `_rebaselined_1432_member_call_callee_name` (drops `direct-callee-name|forEach` from one member call). Neither side's value reproduces under the merged tree. Recomputed under both: counts 5753/18403/2563 and fixture_count 141 match main's committed counts EXACTLY (this branch's change is emission-only), scaling 1.061 < 1.5, and csharp / cpp / typescript measure byte-for-byte at this branch's committed values (main did not touch them since the merge-base) while the other 11 languages report ok \u2014 the check that the merge replayed nothing else into the capture stream. Prior (main) aeafc7a87402c933786ef582b7c98683b1822b78fa909e605cb97552867fa0d5 and (this branch) 973d702510002dda76166e017c5eca90cae37a139f5a512877b7a1b04ad19dc5 -> a9d3f0db7547ff47856159debf15d2a6f427efca97a6af27b4004174ed432132."
}
}

View file

@ -0,0 +1,8 @@
{
"_comment": "Baselines for bench/spring-config-bindings/measure.mjs --check (#2412). fingerprint is sha256 over position-free Kotlin config-consumer fact ids on the wildcard_large corpus (800 files × 2 @Value properties + 1 @ConfigurationProperties class = 2400 facts). Both arms must fingerprint identically: the wildcard arm adds a sibling nested type named `Value`, which must not suppress the imported Spring annotation. Budgets are timing gates with CI headroom.",
"fingerprint": "34776f883427479befbeb3c09eaae2260ba778e769bff195044d3cb8f5ad9889",
"scaling_budget": 1.6,
"_scaling_note": "(t_large/t_small)/(800/250) on the wildcard arm. Measured ~0.99.",
"widening_overhead_budget": 1.8,
"_widening_overhead_note": "wildcard_large_ms / exact_large_ms. The exact-import control resolves each annotation from imports.exact before any shadow check, so this isolates the wildcard path's per-annotation lexical shadow walk. Measured ~1.09; budget guards against a per-annotation rescan of the file's declarations."
}

View file

@ -0,0 +1,164 @@
/**
* Build-free throughput + identity bench for Kotlin Spring config-consumer
* capture (#2412).
*
* Arms (identical corpora except the import style):
* - exact: explicit `import ...annotation.Value` control, which resolves the
* annotation from `imports.exact` before any shadow check runs
* - wildcard: `import ...annotation.*` feature path, where every simple-name
* annotation pays the lexical local-type shadow walk. Each file also
* declares a sibling nested type named `Value` that must NOT suppress the
* Spring annotation the file-wide-shadow regression fixed on this branch.
*
* Parsing is prepared outside the timer; the measured path is the capture
* function the Kotlin worker calls on its own AST.
*
* Usage:
* node --import tsx bench/spring-config-bindings/measure.mjs
* node --import tsx bench/spring-config-bindings/measure.mjs --check
*/
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import Parser from 'tree-sitter';
import { SupportedLanguages } from 'gitnexus-shared';
import { getLanguageGrammar } from '../../src/core/tree-sitter/parser-loader.ts';
import { captureKotlinSpringConfigConsumerFacts } from '../../src/core/ingestion/languages/kotlin/spring-config-bindings.ts';
import { fingerprintIds, minSample, runBaselineCheck } from '../lib/identity-guard.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const BASELINE_PATH = path.resolve(__dirname, 'baselines.json');
const SMALL = 250;
const LARGE = 800;
const REPS = 15;
const WARMUP = 5;
/** Two @Value properties plus one @ConfigurationProperties class per file. */
const FACTS_PER_FILE = 3;
function consumerSource(i, mode) {
const imports =
mode === 'wildcard'
? `import org.springframework.beans.factory.annotation.*
import org.springframework.boot.context.properties.*`
: `import org.springframework.beans.factory.annotation.Value
import org.springframework.boot.context.properties.ConfigurationProperties`;
return `package bench.config
${imports}
class Shadowing${i} {
class Value
}
@ConfigurationProperties(prefix = "svc.${i}")
class Props${i} {
var endpoint: String? = null
}
class Consumer${i} {
@Value("\\\${app.key${i}}")
var timeout: Int = 0
@Value("\\\${app.other${i}:5}")
var other: String? = null
fun decoy() {}
}
`;
}
/** Position-free fact identity, so both arms are directly comparable. */
function factId(fact) {
const consumer = fact.consumer;
return consumer.kind === 'value'
? `value|${consumer.fieldName}|${[...consumer.keys].sort().join(',')}`
: `configuration-properties|${consumer.className}|${consumer.prefix}`;
}
function prepare(mode, fileCount) {
const files = [];
const lang = getLanguageGrammar(SupportedLanguages.Kotlin);
for (let i = 0; i < fileCount; i++) {
const parser = new Parser();
parser.setLanguage(lang);
const filePath = `bench/${mode}/Consumer${i}.kt`;
files.push({ tree: parser.parse(consumerSource(i, mode)), filePath, parser });
}
return files;
}
function runAll(files) {
const ids = [];
for (const f of files) {
for (const fact of captureKotlinSpringConfigConsumerFacts(f.tree.rootNode, f.filePath)) {
ids.push(factId(fact));
}
}
return ids;
}
function measure(mode, fileCount) {
const files = prepare(mode, fileCount);
const { last, ms } = minSample(() => runAll(files), WARMUP, REPS);
return {
files: fileCount,
ms,
facts: last.length,
fingerprint: fingerprintIds(last),
};
}
function failIfNeeded(current, errors) {
if (errors.length === 0) return;
console.error(JSON.stringify({ report: current, errors }, null, 2));
process.exit(1);
}
function runFactCountCheck(current, expectedCounts) {
const errors = [];
for (const [arm, expected] of Object.entries(expectedCounts)) {
const actual = current[arm]?.facts;
if (actual !== expected) errors.push(`${arm}.facts ${String(actual)} != ${expected}`);
}
failIfNeeded(current, errors);
}
/**
* A wildcard import plus a sibling `Value` declaration must capture exactly the
* facts the explicit-import control captures.
*/
function runFingerprintParityCheck(current, leftArm, rightArm) {
const left = current[leftArm]?.fingerprint;
const right = current[rightArm]?.fingerprint;
failIfNeeded(
current,
left === right ? [] : [`${leftArm}.fingerprint ${left} != ${rightArm}.fingerprint ${right}`],
);
}
const report = {
exact_small: measure('exact', SMALL),
exact_large: measure('exact', LARGE),
wildcard_small: measure('wildcard', SMALL),
wildcard_large: measure('wildcard', LARGE),
};
report.scaling_ratio = Number(
(report.wildcard_large.ms / report.wildcard_small.ms / (LARGE / SMALL)).toFixed(3),
);
report.widening_overhead = Number(
(report.wildcard_large.ms / Math.max(report.exact_large.ms, 0.001)).toFixed(3),
);
report.fingerprint = report.wildcard_large.fingerprint;
runFactCountCheck(report, {
exact_large: LARGE * FACTS_PER_FILE,
wildcard_large: LARGE * FACTS_PER_FILE,
});
runFingerprintParityCheck(report, 'exact_large', 'wildcard_large');
if (!process.argv.includes('--check')) {
console.log(JSON.stringify(report, null, 2));
process.exit(0);
}
runBaselineCheck(report, BASELINE_PATH);

View file

@ -0,0 +1,91 @@
#!/usr/bin/env node
/**
* Optional V8 sidecar warm-load bench (#3089).
*
* Not part of `npm test`. Measures repeated warm loads of the `.v8` ParsedFile
* shards already on disk through the production loader. Replay of identical
* shards is throughput-only it is not unique-object scale.
*
* Copies the store into a temporary workspace first. The source cache is
* never mutated.
*
* Usage (from gitnexus/):
* node --expose-gc --import tsx bench/v8-sidecar/measure.mjs <storagePath>
*/
import { cp, mkdtemp, readdir, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { performance } from 'node:perf_hooks';
import { loadParsedFilesForPaths } from '../../src/storage/parsedfile-store.ts';
import { inspectV8Cache } from '../../src/storage/v8-sidecar.ts';
const srcStorage = process.argv[2];
if (!srcStorage) {
console.error('usage: node --expose-gc --import tsx bench/v8-sidecar/measure.mjs <storagePath>');
process.exit(2);
}
const srcStoreDir = path.join(srcStorage, 'parsedfile-store');
const benchRoot = await mkdtemp(path.join(tmpdir(), 'gnx-v8-bench-'));
const storeDir = path.join(benchRoot, 'parsedfile-store');
const PATH_SOURCE_SHARDS = 8;
const RUNS = 3;
try {
await cp(srcStoreDir, storeDir, { recursive: true });
const names = (await readdir(storeDir))
.filter((f) => f.endsWith('.v8') && !f.includes('.v8.'))
.sort();
if (names.length === 0) {
throw new Error(
`no .v8 ParsedFile shards in ${srcStoreDir} — run an analyze that populates the store first`,
);
}
const want = new Set();
let sourceShards = 0;
for (const name of names) {
const inspected = await inspectV8Cache(path.join(storeDir, name));
if (!inspected) continue;
sourceShards++;
for (const filePath of inspected.paths) want.add(filePath);
if (sourceShards >= PATH_SOURCE_SHARDS) break;
}
if (want.size === 0) {
throw new Error(
`no file paths readable from ${names.length} shard(s) in ${srcStoreDir} — shards may be from another Node/V8 runtime, so re-analyze with this runtime`,
);
}
const rss = () => Math.round(process.memoryUsage().rss / 1024 / 1024);
const heap = () => Math.round(process.memoryUsage().heapUsed / 1024 / 1024);
const run = async (label) => {
if (typeof globalThis.gc === 'function') globalThis.gc();
const t0 = performance.now();
const loaded = await loadParsedFilesForPaths(benchRoot, want);
const ms = Math.round(performance.now() - t0);
if (loaded.size !== want.size) {
throw new Error(`incomplete V8 load: requested ${want.size} paths but loaded ${loaded.size}`);
}
if (typeof globalThis.gc === 'function') globalThis.gc();
console.log(
JSON.stringify({
label,
shards: names.length,
wantPaths: want.size,
files: loaded.size,
ms,
rssMiB: rss(),
heapUsedMiB: heap(),
}),
);
};
for (let i = 1; i <= RUNS; i++) {
await run(`v8-load-${i}`);
}
} finally {
await rm(benchRoot, { recursive: true, force: true });
}

View file

@ -1,12 +1,12 @@
{
"name": "gitnexus",
"version": "1.6.9",
"version": "1.6.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gitnexus",
"version": "1.6.9",
"version": "1.6.10",
"hasInstallScript": true,
"license": "PolyForm-Noncommercial-1.0.0",
"dependencies": {
@ -14,15 +14,18 @@
"@modelcontextprotocol/sdk": "^1.0.0",
"@scarf/scarf": "^1.4.0",
"busboy": "^1.6.0",
"chokidar": "^5.0.0",
"cli-progress": "^3.12.0",
"commander": "^15.0.0",
"cors": "^2.8.5",
"express": "^5.2.1",
"express-rate-limit": "^8.4.1",
"fast-xml-parser": "^5.11.1",
"glob": "^13.0.6",
"graphology": "^0.26.0",
"graphology-indices": "^0.17.0",
"graphology-utils": "^2.3.0",
"graphql": "^17.0.2",
"ignore": "^7.0.5",
"js-yaml": "^5.0.0",
"jsonc-parser": "^3.3.1",
@ -826,9 +829,6 @@
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -845,9 +845,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -864,9 +861,6 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -883,9 +877,6 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -902,9 +893,6 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -921,9 +909,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -940,9 +925,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -959,9 +941,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -978,9 +957,6 @@
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -1003,9 +979,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -1028,9 +1001,6 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -1053,9 +1023,6 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -1078,9 +1045,6 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -1103,9 +1067,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -1128,9 +1089,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -1153,9 +1111,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -1444,6 +1399,18 @@
}
}
},
"node_modules/@nodable/entities": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz",
"integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/nodable"
}
],
"license": "MIT"
},
"node_modules/@oxc-project/types": {
"version": "0.144.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz",
@ -1619,9 +1586,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1639,9 +1603,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -1659,9 +1620,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1679,9 +1637,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1699,9 +1654,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1719,9 +1671,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -1960,9 +1909,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "26.2.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
"integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==",
"version": "26.3.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.3.0.tgz",
"integrity": "sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw==",
"devOptional": true,
"license": "MIT",
"dependencies": {
@ -2238,6 +2187,18 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/anynum": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz",
"integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/apache-arrow": {
"version": "21.1.0",
"resolved": "https://registry.npmjs.org/apache-arrow/-/apache-arrow-21.1.0.tgz",
@ -2467,6 +2428,21 @@
"url": "https://github.com/chalk/chalk-template?sponsor=1"
}
},
"node_modules/chokidar": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
"license": "MIT",
"dependencies": {
"readdirp": "^5.0.0"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/chownr": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
@ -3091,6 +3067,45 @@
],
"license": "BSD-3-Clause"
},
"node_modules/fast-xml-builder": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz",
"integrity": "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"path-expression-matcher": "^1.6.2",
"xml-naming": "^0.3.0"
}
},
"node_modules/fast-xml-parser": {
"version": "5.11.1",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.11.1.tgz",
"integrity": "sha512-TBw6K/fxoQGGjCmZDw9w/ZwP3uDcnTM4YH/g+PFRWr8sbe5idXtxNN6vITh4+1ruCZaho6uBFurElsA7F0zzgw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"@nodable/entities": "^3.0.0",
"fast-xml-builder": "^1.2.0",
"is-unsafe": "^2.0.0",
"path-expression-matcher": "^1.6.2",
"strnum": "^2.4.2",
"xml-naming": "^0.3.0"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@ -3377,6 +3392,15 @@
"graphology-types": ">=0.23.0"
}
},
"node_modules/graphql": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/graphql/-/graphql-17.0.2.tgz",
"integrity": "sha512-FRWbddMxfkjiB7z+aQDWIR+E34xo9I8c9mtK2RPv8PmMzKRvrdsreHL/Ui/TmwHJfhHChEtsFPyMHKI+xuarQQ==",
"license": "MIT",
"engines": {
"node": "^22.0.0 || ^24.0.0 || ^25.0.0 || >=26.0.0"
}
},
"node_modules/guid-typescript": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz",
@ -3542,6 +3566,18 @@
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT"
},
"node_modules/is-unsafe": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.2.tgz",
"integrity": "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/isexe": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz",
@ -3616,9 +3652,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.3.0.tgz",
"integrity": "sha512-muutsYr+e2+d3rTgUGslq5rxbBlUy3cJ61IsHag2QNDQV+7zXWjkUpmALIajhrlLlrgRUiymj6U3zUr/TMK84Q==",
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.4.0.tgz",
"integrity": "sha512-jE7vUJIebKzYQI5xu4co5CRBDlDEYnHrdzsxs4O2giCz4v2SbVMYKpmt1D9L38OKQAeCWmrOTRiCV93u0UkaJA==",
"funding": [
{
"type": "github",
@ -3831,9 +3867,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -3855,9 +3888,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -3879,9 +3909,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -3903,9 +3930,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -4335,15 +4359,15 @@
}
},
"node_modules/onnxruntime-common": {
"version": "1.27.0",
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.27.0.tgz",
"integrity": "sha512-3KxL5wIVqa8Ex08jxSzncm9CMgw8CjOFyOQ7SxvG9o0cVLlhTNKXyIQuTbtX4tGPJEf73OER2xrjt4HJSBL4ow==",
"version": "1.29.0",
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.29.0.tgz",
"integrity": "sha512-/F63/e2VJoaVXGGNu6S5QH7jivBThGO95OzAVXXQ8hTta/b1QxI8udHa6cI3+3mAb5WWIIaMMwfZw01oivjJ1g==",
"license": "MIT"
},
"node_modules/onnxruntime-node": {
"version": "1.27.0",
"resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.27.0.tgz",
"integrity": "sha512-QEzGwrvNBgv4uPVdnbHsOGG4G6T96mdlcFI8aAKPjMU8wOPpVocPXb6k3QGkaZagVTv2G9Bnnbo6Z3JdXr1fQw==",
"version": "1.29.0",
"resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.29.0.tgz",
"integrity": "sha512-WjiVVB72riILz8HbYvxvmjKyE/WmkYoSfKY++axo5jAR609HQg8MwiG/HhShpTcJfmmAdzxxmB+MMST3A+SiPA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@ -4353,9 +4377,9 @@
"linux"
],
"dependencies": {
"adm-zip": "^0.5.16",
"adm-zip": "^0.6.0",
"global-agent": "^4.1.3",
"onnxruntime-common": "1.27.0"
"onnxruntime-common": "1.29.0"
}
},
"node_modules/onnxruntime-web": {
@ -4407,6 +4431,21 @@
"node": ">= 0.8"
}
},
"node_modules/path-expression-matcher": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz",
"integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@ -4594,9 +4633,9 @@
"license": "MIT"
},
"node_modules/protobufjs": {
"version": "7.6.4",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz",
"integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==",
"version": "7.6.6",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz",
"integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"optional": true,
@ -4700,6 +4739,19 @@
"rc": "cli.js"
}
},
"node_modules/readdirp": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz",
"integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/real-require": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
@ -5140,6 +5192,21 @@
"node": ">=0.10.0"
}
},
"node_modules/strnum": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.2.tgz",
"integrity": "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"anynum": "^1.0.1"
}
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@ -5825,6 +5892,21 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/xml-naming": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz",
"integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

View file

@ -1,6 +1,6 @@
{
"name": "gitnexus",
"version": "1.6.9",
"version": "1.6.10",
"description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
"author": "Abhigyan Patwari",
"license": "PolyForm-Noncommercial-1.0.0",
@ -60,15 +60,18 @@
"@modelcontextprotocol/sdk": "^1.0.0",
"@scarf/scarf": "^1.4.0",
"busboy": "^1.6.0",
"chokidar": "^5.0.0",
"cli-progress": "^3.12.0",
"commander": "^15.0.0",
"cors": "^2.8.5",
"express": "^5.2.1",
"express-rate-limit": "^8.4.1",
"fast-xml-parser": "^5.11.1",
"glob": "^13.0.6",
"graphology": "^0.26.0",
"graphology-indices": "^0.17.0",
"graphology-utils": "^2.3.0",
"graphql": "^17.0.2",
"ignore": "^7.0.5",
"js-yaml": "^5.0.0",
"jsonc-parser": "^3.3.1",

View file

@ -3,7 +3,7 @@
*
* WHY THIS EXISTS. `run-cross-platform.ts` used to hand vitest the whole file
* list plus `--shard=i/n`, and vitest partitions by file COUNT. Runtime on this
* suite is wildly uneven measured on the Windows runner, `cli-e2e` is 361 s
* suite is wildly uneven measured on the Windows runner, `cli-e2e` is 621 s
* and `worker-pool` 221 s, while most files are under a second so a
* count-split routinely put several of the heaviest suites on one shard. That
* is #2449, and this file's sibling header has documented the symptom ("the
@ -44,7 +44,9 @@
* partition depend on the very machine load it is trying to protect against.
*/
export const WINDOWS_WEIGHTS_SEC: Readonly<Record<string, number>> = {
'test/integration/cli-e2e.test.ts': 361,
// Re-measured after the analyze --watch e2e landed in #3072. The previous
// 361 s entry undercharged this suite and left shard 1 close to the watchdog.
'test/integration/cli-e2e.test.ts': 621,
'test/integration/worker-pool.test.ts': 222,
'test/unit/incremental-vector-extension-ordering.test.ts': 87,
// ESTIMATE, not a measurement (#2841): this suite drives more full
@ -65,6 +67,16 @@ export const WINDOWS_WEIGHTS_SEC: Readonly<Record<string, number>> = {
'test/integration/antigravity-hook-e2e.test.ts': 7,
'test/unit/index-lock.test.ts': 5,
'test/unit/setup.test.ts': 5,
// ESTIMATE, not a measurement. This file asserts almost nothing; it READS —
// one 4893-file pass over every tracked text file, plus an 830-file pass over
// `src/`. Measured at 2.3 s and 0.3 s per pass on a virtualised and a local
// Linux filesystem respectively, so the cost is entirely per-file open
// latency, which is the term Windows inflates most (NTFS plus Defender on
// every read). Scaled from the slower Linux figure to keep the split
// conservative rather than let the 8 s PER_FILE_OVERHEAD floor under-charge
// a file that touches more paths than anything else here. Replace with a real
// figure after the first green Windows matrix run.
'test/unit/source-control-bytes.test.ts': 15,
};
/**

View file

@ -90,6 +90,7 @@ const PLATFORM_LOGIC = [
'test/unit/ignore-service.test.ts',
'test/unit/group/bridge-db.test.ts',
'test/unit/group/bridge-db-edge.test.ts',
'test/unit/group/fs-utils.test.ts',
'test/unit/onnxruntime-node-resolver.test.ts',
// Windows cmd.exe arg-quoting + compose-and-spawn for the npm install (#2372):
// the quoting rules and win32 single-string spawn shape are OS-sensitive, so
@ -113,6 +114,9 @@ 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',
// #3073: cwd-based repository selection canonicalizes real paths, compares
// platform separators/case, and rejects nested Git-boundary fallthrough.
'test/unit/calltool-dispatch.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.
@ -140,6 +144,7 @@ const LBUG_NATIVE = [
// opens them through the pool adapter (native addon + bridge file locking).
// Windows is skipped in-file (describeReopen) due to the bridge reopen lock.
'test/integration/group/cross-trace-e2e.test.ts',
'test/integration/group/graphql-resolve-symbol.test.ts',
'test/integration/local-backend.test.ts',
'test/integration/local-backend-calltool.test.ts',
'test/integration/search-core.test.ts',
@ -208,6 +213,18 @@ const SPAWN_CLI = [
// 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',
// The per-group sync lock (R9), same class of guarantee one level up: real
// child processes contend for one group's lock while this process runs a real
// `syncGroup`, and the CLI case spawns the real command. Everything that
// varies here is platform-owned — which backend `selectBackend()` picks
// (Windows named pipe / Linux abstract socket / macOS file lock), kernel
// auto-release on SIGKILL vs. the file backend's pid-liveness reclaim, and
// `mkdir` over an occupied path. The fail-closed cases pin
// GITNEXUS_INDEX_LOCK_BACKEND=file so the filesystem branch is exercised on
// every OS rather than only where it is the default; no case is skipped on
// any platform, because a skipped case turns "a sync that cannot be protected
// does not run" into a claim that holds on Ubuntu only.
'test/integration/group/group-sync-lock-concurrency.test.ts',
// The three `dist/` module-load closure guards, all built on the shared
// child-process probe in `test/helpers/module-load-probe.ts`. That probe IS
// the platform-varying part: it spawns `process.execPath` in array form,
@ -220,7 +237,7 @@ const SPAWN_CLI = [
// Cheap: measured on the Windows runner at 448 ms, 53 ms and sub-second. An
// earlier attempt to register them still turned the matrix red — not from
// their own cost, but because vitest sharded by file COUNT, so inserting any
// file re-partitioned the list and happened to cluster `cli-e2e` (361 s) with
// file re-partitioned the list and happened to cluster `cli-e2e` (621 s) with
// `cli-limit-e2e` (75 s) on one shard. The split is weight-aware now
// (`scripts/cross-platform-shard.ts`), so a cheap file can no longer move a
// heavy one.
@ -259,8 +276,31 @@ const NATIVE_ADDON_SMOKE = [
// platforms (CRLF, symlinks, permissions, temp dirs)
const FILESYSTEM = [
'test/integration/filesystem-walker.test.ts',
'test/integration/watch-filesystem.test.ts',
'test/integration/markdown-processor-crlf.test.ts',
'test/integration/ignore-and-skip-e2e.test.ts',
// Pins that the bridge pairing verdict is measured before the database is
// opened. The property it protects is about mtime behavior across OS and
// filesystem, and the alternative — really opening the bridge — cannot run on
// Windows at all (in-process write→read reopen of the same bridge.lbug is a
// documented limitation). Running it on every platform is the whole point:
// Windows is where an unverified assumption about mtime would hurt most.
'test/unit/group/bridge-pairing-precedes-open.test.ts',
// The raw-control-byte guard reads every tracked text file `git ls-files`
// reports — 4893 of them — and decides membership from the git path, which is
// always `/`-separated no matter what the host separator is. Both halves of
// that are platform-varying: the collector basename-matches with
// `path.posix.basename` against `git ls-files -z` output while the reads go
// through `path.join`, so on Windows the same string is consumed under two
// separator conventions in one pass, and only a real windows-latest run
// proves they agree. It is also the file-count-heaviest read loop in the
// suite, so it is where a per-file filesystem cost (NTFS + Defender, or
// macOS's slower stat path) would show up first. No case is skipped on any
// platform: a guard that only holds on Ubuntu is not a guard on the file
// whose NUL it exists to catch. Budget: the heaviest single case is one
// 4893-file pass — 2.3 s on a slow virtualised filesystem, 0.34 s on a local
// disk — against a 30 s testTimeout.
'test/unit/source-control-bytes.test.ts',
];
const ALL_CROSS_PLATFORM = [

View file

@ -21,13 +21,20 @@ Run from the project root. This parses all source files, builds the knowledge gr
| Flag | Effect |
| -------------- | ---------------------------------------------------------------- |
| `--watch` | Keep a Git repository index current with serialized refreshes |
| `--debounce <ms>` | Watch quiet period before refresh (default: 300 ms) |
| `--force` | Force full re-index even if up to date |
| `--embeddings` | Enable embedding generation for semantic search (off by default) |
| `--drop-embeddings` | Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` preserves them. |
| `--pdg` | Build the program-dependence layers used by `explain` and `pdg_query` (taint, CDG, and REACHING_DEF). |
| `--spring-actuator <path>` | Import opt-in Spring Boot Actuator mappings, beans, conditions, configprops, and env snapshots. Forces a full rebuild; unsupported with `--watch`. |
**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook detects staleness after `git commit` and `git merge` and notifies the agent to run `analyze` — the hook does not run analyze itself, to avoid blocking the agent for up to 120s and risking KuzuDB corruption on timeout.
For Spring runtime enrichment, pass a JSON bundle, one endpoint JSON file, or a directory containing endpoint files. Route evidence is authoritative only when `runtimeConfirmed === true`; `runtimeSource` records provenance and may also accompany `handler-conflict`. Env/configprops values are never persisted.
Use `node .gitnexus/run.cjs analyze --watch` for a long-lived local Git repository. It performs an initial analysis, queues scanner-admitted file changes, and retries intact failed batches with bounded backoff. Watch refreshes update only the graph: they skip AGENTS.md / CLAUDE.md injection and standard skill installation, so run a one-shot `analyze` when those generated files need updating. Watch rejects one-shot or context-output flags including `--force`, embedding flags, `--skills`, `--default-branch`, `--skip-agents-md`, `--skip-skills`, `--no-stats`, `--self-commit`, `--index-only`, and `--skip-git`. It never pulls remotes. Scheduled remote clone/pull is a different command: `gitnexus auto-sync`. Bare `gitnexus watch` is reserved and does not start either job. Running MCP and `serve` processes periodically check for a published replacement and reopen it without a restart. MCP checks are throttled to once every five seconds, so a tool call before the next check can briefly use the previous index.
### status — Check index freshness
```bash
@ -55,15 +62,19 @@ Deletes the `.gitnexus/` directory and unregisters the repo from the global regi
node .gitnexus/run.cjs wiki
```
Generates repository documentation from the knowledge graph using an LLM. Requires an API key (saved to `~/.gitnexus/config.json` on first use).
Generates repository documentation from the knowledge graph using an LLM. HTTP providers require an API key (saved to `~/.gitnexus/config.json` on first use). Local CLI providers (`--provider cursor|claude|codex|opencode|grok`) use your existing CLI login.
| Flag | Effect |
| ------------------- | ----------------------------------------- |
| `--force` | Force full regeneration |
| `--force` | Force full regeneration, also required to re-generate an existing wiki in a different language |
| `--provider <name>` | LLM provider: minimax, openai, openrouter, azure, custom, cursor, claude, codex, opencode, or grok (default: minimax). Local CLIs (`cursor`, `claude`, `codex`, `opencode`, `grok`) use your existing CLI login and skip `--api-key`. |
| `--model <model>` | LLM model (default: MiniMax-M3) |
| `--base-url <url>` | LLM API base URL |
| `--api-key <key>` | LLM API key |
| `--concurrency <n>` | Parallel LLM calls (default: 3) |
| `--timeout <seconds>` | LLM request timeout in seconds (default: disabled) |
| `--retries <n>` | Max LLM retry attempts per request (default: 3) |
| `--lang <lang>` | Output language for generated documentation (e.g. english, chinese, spanish, japanese) |
| `--gist` | Publish wiki as a public GitHub Gist |
### list — Show all indexed repos
@ -82,5 +93,5 @@ Lists all repositories registered in `~/.gitnexus/registry.json`. The MCP `list_
## Troubleshooting
- **"Not inside a git repository"**: Run from a directory inside a git repo
- **Index is stale after re-analyzing**: Restart Claude Code to reload the MCP server
- **Index is stale after re-analyzing**: Wait for the next MCP tool call to reopen the published index; this normally takes no more than five seconds
- **Embeddings slow**: Omit `--embeddings` (it's off by default) or set `OPENAI_API_KEY` for faster API-based embedding

View file

@ -93,6 +93,15 @@ dispatch, cross-language calls), so few-callers ⇒ LOW does **not** apply. The
result carries a `riskNote` saying so. Confirm with a text search before
treating the symbol as safe to change or delete.
`risk` is the edit gate: warn on HIGH/CRITICAL and stop on UNKNOWN until the
uncertainty is resolved. Within single-repo mode, compare File and symbol
targets with local `riskSharedAxes` (direct/total only). Within group mode,
compare only group results: their `riskSharedAxes` overlays resolved
cross-repo crossings on that local value. Never use either field to waive the
edit gate. Check `riskScale.unusedAxes` before comparing kinds: MCP File walks
omit process/module axes, while web Graph-RAG expands File targets to in-file
symbols before enrichment.
## Tools
**impact** — the primary tool for symbol blast radius. If MCP is unavailable, use `node .gitnexus/run.cjs impact <symbol> --direction upstream --repo .` instead:

View file

@ -11,6 +11,7 @@ import path from 'path';
import { fileURLToPath } from 'url';
import { type GeneratedSkillInfo } from './generated-skill.js';
import { STANDARD_SKILL_CATALOG } from './standard-skills.js';
import { isEnoent } from './editor-targets.js';
import { logger } from '../core/logger.js';
// ESM equivalent of __dirname
@ -42,6 +43,8 @@ export interface AIContextOptions {
* "no PDG layer" note, so advertising it on a non-`--pdg` index is noise.
*/
hasPdg?: boolean;
/** Whether this index includes opt-in Spring Actuator runtime evidence. */
hasSpringActuator?: boolean;
}
const GITNEXUS_START_MARKER = '<!-- gitnexus:start -->';
@ -136,6 +139,8 @@ export interface GitNexusContentOptions {
* line below false (default) omits it, so a non-pdg index doesn't advertise
* a tool that only returns a "no PDG layer" note. */
hasPdg?: boolean;
/** Whether Route nodes may carry Spring Actuator runtime evidence. */
hasSpringActuator?: boolean;
}
export function generateGitNexusContent(
@ -151,6 +156,7 @@ export function generateGitNexusContent(
runnerPath = '.gitnexus/run.cjs',
defaultBranch = 'main',
hasPdg = false,
hasSpringActuator = false,
} = opts;
const generatedRows =
generatedSkills && generatedSkills.length > 0
@ -218,16 +224,19 @@ This project is indexed by GitNexus as **${projectName}**${noStats ? '' : ` (${s
## Always Do
- **MUST run impact analysis before editing.** Use \`impact({target: "symbolName", direction: "upstream"})\` (MCP) or \`${runner} impact "symbolName" --direction upstream --repo .\` (CLI fallback); report callers, processes, and risk. Never substitute grep for graph analysis.${
- **MUST run impact before editing.** Use \`impact({target: "symbolName", direction: "upstream"})\` or \`${runner} impact "symbolName" --direction upstream --repo .\`; report callers, processes, and risk. Never substitute grep for graph analysis.${
hasPdg
? ` For unified PDG impact, add \`mode: "pdg"\` with optional \`line: <N>\` — it returns statement-level \`affectedStatements\` over CDG + REACHING_DEF and inter-procedural symbols in \`interproceduralByDepth\`/\`byDepth\`; no-layer/degraded PDG results are UNKNOWN-risk notes (\`--pdg\` layer). CLI equivalent: \`${runner} impact "symbolName" --direction upstream --mode pdg --line <N> --repo .\`.`
: ''
}
- **MUST analyze graph changes before committing.** Use \`detect_changes({scope: "all"})\` (MCP) or \`${runner} detect-changes --scope all --repo .\` (CLI fallback). \`partial: true\` or \`truncated: true\` is not a clean check — a zero means unseen, not unaffected; re-run it. For regression review: \`detect_changes({scope: "compare", base_ref: ${JSON.stringify(markdownSafeBranch(defaultBranch))}})\` or \`${runner} detect-changes --scope compare --base-ref ${JSON.stringify(markdownSafeBranch(defaultBranch))} --repo .\`.
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
- MUST warn on HIGH/CRITICAL \`risk\` pre-edit; never use \`riskSharedAxes\` to waive a HIGH/CRITICAL \`risk\` warning. Compare File/symbol: MCP File omits axes; Graph-RAG expands File.
- **MUST treat \`risk: UNKNOWN\` as unresolved, not as low.** An empty caller set is not evidence the symbol is unused — it can also mean the callers are not resolvable by the index (plain-object property access, dynamic dispatch, cross-language calls). \`impact\` pairs \`UNKNOWN\` with a \`riskNote\` saying so. Confirm with a text search before treating the symbol as safe to change or delete; do not proceed on the strength of a zero.
- When exploring unfamiliar code, use \`query({search_query: "concept"})\` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When you need full context on a specific symbol callers, callees, which execution flows it participates in use \`context({name: "symbolName"})\`.
- **MUST use \`query({search_query: "concept"})\` for concepts/flows, \`context({name: "symbolName"})\` for a named symbol, or \`impact\` for blast radius, on read-only callers, dependencies, imports, or execution flow.** Graph first; text search only for empty/\`UNKNOWN\`/literals.${
hasSpringActuator
? '\n- Spring Actuator runtime evidence is enabled. A Route is authoritative only when `runtimeConfirmed === true`; `runtimeSource` is provenance and may also describe conflicts. Snapshot values are never persisted.'
: ''
}
- For security review, \`explain({target: "fileOrSymbol"})\` lists taint findings (source→sink flows; needs \`analyze --pdg\`).${
hasPdg
? `\n- For control/data dependence, \`pdg_query({mode: "controls", target: "fileOrSymbol"})\` answers "under what condition does X run?" (CDG, incl. guard clauses) and \`pdg_query({mode: "flows", target, variable})\` traces "where does variable Y flow?" (REACHING_DEF). \`--pdg\` layer.`
@ -432,17 +441,84 @@ export async function shouldMirrorSkillsToAgents(repoPath: string): Promise<bool
}
}
const SKILL_PRESERVE_HINT =
'delete the file to refresh from the bundled template, or pass --skip-skills to skip skill install';
async function readUtf8IfPresent(filePath: string): Promise<string | null> {
try {
return await fs.readFile(filePath, 'utf-8');
} catch (err) {
if (isEnoent(err)) return null;
throw err;
}
}
function skillBytesDiverge(existing: string | null, bundled: string): boolean {
return existing !== null && existing !== bundled;
}
/** Write bundled skill bytes unless an existing file already differs. */
async function writeSkillUnlessDivergent(filePath: string, content: string): Promise<boolean> {
const existing = await readUtf8IfPresent(filePath);
if (skillBytesDiverge(existing, content)) {
logger.warn(`Preserved customized skill ${filePath}; ${SKILL_PRESERVE_HINT}.`);
return true;
}
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, content, 'utf-8');
return false;
}
async function inspectLegacySkillDir(
legacyDir: string,
): Promise<{ nestedExisting: string | null; hasSiblings: boolean } | null> {
let entries: string[];
try {
entries = await fs.readdir(legacyDir);
} catch (err) {
if (isEnoent(err)) return null;
throw err;
}
const nestedExisting = entries.includes('SKILL.md')
? await fs.readFile(path.join(legacyDir, 'SKILL.md'), 'utf-8')
: null;
return {
nestedExisting,
hasSiblings: entries.some((entry) => entry !== 'SKILL.md'),
};
}
function formatSkillInstallLine(
prefix: string,
total: number,
preserved: number,
allWrittenSuffix: string,
partialSuffix: string,
): string {
if (preserved > 0) {
return `${prefix} (${total - preserved} written, ${preserved} ${partialSuffix})`;
}
return `${prefix} (${total} ${allWrittenSuffix})`;
}
/**
* Install GitNexus skills as direct children of .claude/skills/
* Works natively with Claude Code, Cursor, and GitHub Copilot.
* Mirrored to .agents/skills/ when .agents/ exists.
*/
async function installSkills(
repoPath: string,
): Promise<{ skills: string[]; agentsMirror: boolean }> {
async function installSkills(repoPath: string): Promise<{
skills: string[];
agentsMirror: boolean;
claudePreserved: number;
agentsPreserved: number;
legacyPreserved: number;
}> {
const skillsDir = path.join(repoPath, '.claude', 'skills');
const legacySkillsDir = path.join(skillsDir, 'gitnexus');
const installedSkills: string[] = [];
let claudePreserved = 0;
let agentsPreserved = 0;
let legacyPreserved = 0;
const agentsMirror = await shouldMirrorSkillsToAgents(repoPath);
for (const skill of STANDARD_SKILL_CATALOG.filter(
@ -452,9 +528,6 @@ async function installSkills(
const skillPath = path.join(skillDir, 'SKILL.md');
try {
// Create skill directory
await fs.mkdir(skillDir, { recursive: true });
// Try to read from package skills directory
const packageSkillPath = path.join(__dirname, '..', '..', 'skills', `${skill.name}.md`);
let skillContent: string;
@ -476,14 +549,13 @@ Use GitNexus tools to accomplish this task.
`;
}
await fs.writeFile(skillPath, skillContent, 'utf-8');
if (await writeSkillUnlessDivergent(skillPath, skillContent)) claudePreserved += 1;
// Mirror to .agents/skills/ for agents that read repo-local skills
if (agentsMirror) {
try {
const agentsSkillDir = path.join(repoPath, '.agents', 'skills', skill.name);
await fs.mkdir(agentsSkillDir, { recursive: true });
await fs.writeFile(path.join(agentsSkillDir, 'SKILL.md'), skillContent, 'utf-8');
const agentsSkillPath = path.join(repoPath, '.agents', 'skills', skill.name, 'SKILL.md');
if (await writeSkillUnlessDivergent(agentsSkillPath, skillContent)) agentsPreserved += 1;
} catch (err) {
logger.warn({ err }, `Warning: Could not mirror skill ${skill.name} to .agents/skills:`);
}
@ -495,7 +567,20 @@ Use GitNexus tools to accomplish this task.
// deep. Remove only the child owned by this installer; unknown siblings
// under the legacy grouping directory may be user-authored and survive.
try {
await fs.rm(path.join(legacySkillsDir, skill.name), { recursive: true, force: true });
const legacyDir = path.join(legacySkillsDir, skill.name);
const nestedSkill = path.join(legacyDir, 'SKILL.md');
const leftover = await inspectLegacySkillDir(legacyDir);
if (leftover !== null && skillBytesDiverge(leftover.nestedExisting, skillContent)) {
logger.warn(`Preserved customized skill ${nestedSkill}; ${SKILL_PRESERVE_HINT}.`);
legacyPreserved += 1;
} else if (leftover?.hasSiblings) {
logger.warn(
`Preserved legacy skill directory ${legacyDir} because it contains operator-owned files.`,
);
legacyPreserved += 1;
} else if (leftover !== null) {
await fs.rm(legacyDir, { recursive: true, force: true });
}
} catch (err) {
logger.warn({ err }, `Warning: Could not remove legacy skill ${skill.name}:`);
}
@ -505,7 +590,13 @@ Use GitNexus tools to accomplish this task.
}
}
return { skills: installedSkills, agentsMirror };
return {
skills: installedSkills,
agentsMirror,
claudePreserved,
agentsPreserved,
legacyPreserved,
};
}
/**
@ -551,6 +642,7 @@ export async function generateAIContextFiles(
runnerPath,
defaultBranch: options?.defaultBranch ?? 'main',
hasPdg: options?.hasPdg ?? false,
hasSpringActuator: options?.hasSpringActuator ?? false,
});
const createdFiles: string[] = [];
@ -583,12 +675,37 @@ export async function generateAIContextFiles(
// Install standard skills directly under .claude/skills/ (unless --skip-skills)
if (!options?.skipSkills) {
const { skills: installedSkills, agentsMirror } = await installSkills(repoPath);
const {
skills: installedSkills,
agentsMirror,
claudePreserved,
agentsPreserved,
legacyPreserved,
} = await installSkills(repoPath);
if (installedSkills.length > 0) {
createdFiles.push(`.claude/skills/gitnexus-*/ (${installedSkills.length} skills)`);
createdFiles.push(
formatSkillInstallLine(
'.claude/skills/gitnexus-*/',
installedSkills.length,
claudePreserved,
'skills',
'preserved',
),
);
if (agentsMirror) {
createdFiles.push(
`.agents/skills/gitnexus-*/ (${installedSkills.length} skills mirrored for .agents)`,
formatSkillInstallLine(
'.agents/skills/gitnexus-*/',
installedSkills.length,
agentsPreserved,
'skills mirrored for .agents',
'preserved for .agents',
),
);
}
if (legacyPreserved > 0) {
createdFiles.push(
`.claude/skills/gitnexus/<name>/ (legacy directories preserved: ${legacyPreserved})`,
);
}
}

View file

@ -30,6 +30,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { readRepoControlFile } from '../config/repo-control-file.js';
import type { AnalyzeOptions } from './analyze-options.js';
export const GITNEXUS_RC_FILENAME = '.gitnexusrc';
@ -59,7 +60,8 @@ type ValueKind =
| 'string-array'
| 'numeric-string'
| 'embeddings'
| 'branch';
| 'branch'
| 'path';
interface KeySpec {
/** The `AnalyzeOptions` field this config key normalizes into. */
@ -107,6 +109,9 @@ const KEY_SPECS: Record<string, KeySpec> = {
// built-in convention set, is otherwise invisible to route_map consumers.
// Listing it here adds it to the cross-file consumer scan.
fetchWrappers: { target: 'fetchWrappers', kind: 'string-array' },
// Explicit local Actuator snapshot input (#2418). The path itself is safe in
// project config; payload contents are never copied into the graph wholesale.
springActuator: { target: 'springActuator', kind: 'path' },
// Auth token AND dims are intentionally CLI/env-only — no embeddingAuthToken
// or embeddingDims key here:
// - the token keeps secrets out of a committed .gitnexusrc;
@ -225,6 +230,17 @@ const normalizeValue = (kind: ValueKind, value: unknown, key: string): unknown =
throw new GitNexusRcError(`${source} must be a string branch name.`);
}
return validateBranchName(value, source);
case 'path': {
if (typeof value !== 'string') {
throw new GitNexusRcError(`${source} must be a file or directory path.`);
}
const trimmed = value.trim();
if (!trimmed) {
throw new GitNexusRcError(`${source} must not be empty.`);
}
assertNoHiddenChars(trimmed, source);
return trimmed;
}
case 'string': {
if (typeof value !== 'string') {
throw new GitNexusRcError(`${source} must be a string.`);
@ -370,7 +386,6 @@ const normalizeLevel = (
*/
export function loadAnalyzeConfig(repoRoot: string): Partial<AnalyzeOptions> | undefined {
const filePath = path.join(repoRoot, GITNEXUS_RC_FILENAME);
let raw: string;
try {
raw = fs.readFileSync(filePath, 'utf-8');
@ -379,6 +394,25 @@ export function loadAnalyzeConfig(repoRoot: string): Partial<AnalyzeOptions> | u
throw new GitNexusRcError(`Could not read ${GITNEXUS_RC_FILENAME}: ${(err as Error).message}`);
}
return parseAnalyzeConfig(raw);
}
/** Load `.gitnexusrc` through the strict bounded reader used by watch mode. */
export async function loadAnalyzeConfigStrict(
repoRoot: string,
): Promise<Partial<AnalyzeOptions> | undefined> {
let raw: string | null;
try {
raw = await readRepoControlFile(repoRoot, GITNEXUS_RC_FILENAME);
} catch (err) {
throw new GitNexusRcError(`Could not read ${GITNEXUS_RC_FILENAME}: ${(err as Error).message}`);
}
return raw === null ? undefined : parseAnalyzeConfig(raw);
}
function parseAnalyzeConfig(rawInput: string): Partial<AnalyzeOptions> {
let raw = rawInput;
// Strip a leading UTF-8 BOM: Node's 'utf-8' decode keeps it, and JSON.parse
// then fails with a confusing "Unexpected token" on an otherwise-valid file
// (#1996 tri-review). Only one leading BOM is stripped; in-string control

View file

@ -15,6 +15,10 @@
* import cycle. `analyze.ts` re-exports the type for existing importers.
*/
export interface AnalyzeOptions {
/** Keep this repository current with serialized incremental refreshes. */
watch?: boolean;
/** Watch quiet period in milliseconds. */
debounce?: string;
force?: boolean;
repairFts?: boolean;
/**
@ -120,6 +124,11 @@ export interface AnalyzeOptions {
* outside the built-in convention still produces `route_map` consumers.
*/
fetchWrappers?: string[];
/**
* Explicit local Spring Boot Actuator snapshot input (#2418). Accepts a JSON
* bundle or a directory containing endpoint JSON files. Disabled by default.
*/
springActuator?: string;
/** OpenAI-compatible embeddings base URL (incl. /v1). Overrides GITNEXUS_EMBEDDING_URL. */
embeddingBaseUrl?: string;
/** Embedding model name. Overrides GITNEXUS_EMBEDDING_MODEL. */

View file

@ -0,0 +1,504 @@
/** Local incremental watch (`gitnexus analyze --watch`). Remote auto-sync lives in `auto-sync.ts`. */
import path from 'node:path';
import fs from 'node:fs/promises';
import { watch, type FSWatcher } from 'chokidar';
import { createWatchIgnorePredicate } from '../config/ignore-service.js';
import {
analyzeFailureMayHaveMutatedLiveIndex,
runFullAnalysis,
type AnalyzeOptions as CoreAnalyzeOptions,
type AnalyzeResult,
} from '../core/run-analyze.js';
import { getGitRoot, hasGitDir } from '../storage/git.js';
import type { AnalyzerRunnerIdentity } from '../storage/repo-manager.js';
import { GITNEXUS_DIR } from '../storage/repo-meta.js';
import {
loadAnalyzeConfigStrict,
mergeAnalyzeOptions,
validateBranchName,
} from './analyze-config.js';
import type { AnalyzeOptions } from './analyze-options.js';
import { ensureHeap } from './analyze.js';
import { cliError, cliInfo, cliWarn } from './cli-message.js';
import {
WATCH_FULL_REFRESH_PATH,
WatchRefreshQueue,
type WatchRefreshError,
} from './watch-queue.js';
const DEFAULT_DEBOUNCE_MS = 300;
const MAX_TIMER_DELAY_MS = 2_147_483_647;
const MAX_FILE_SIZE_KB = 32 * 1024;
const TRANSIENT_WATCH_ERROR_CODES = new Set(['EACCES', 'ENOENT', 'ENOTDIR', 'EPERM']);
export type WatchCliOptions = AnalyzeOptions;
function posixWatchPath(filePath: string): string {
return filePath.replace(/\\/g, '/').replace(/^\.\/+/, '');
}
export function isRelevantWatchPath(filePath: string): boolean {
const normalized = posixWatchPath(filePath);
return (
normalized.length > 0 &&
normalized !== '.' &&
!normalized.startsWith('../') &&
!path.posix.isAbsolute(normalized) &&
!path.win32.isAbsolute(filePath)
);
}
function isIgnoreControlPath(filePath: string): boolean {
const normalized = posixWatchPath(filePath);
return normalized === '.gitignore' || normalized === '.gitnexusignore';
}
function isConfigControlPath(filePath: string): boolean {
return posixWatchPath(filePath) === '.gitnexusrc';
}
function isAnalyzerOwnedWatchPath(filePath: string): boolean {
const normalized = posixWatchPath(filePath).replace(/\/+$/, '');
return normalized === GITNEXUS_DIR || normalized.startsWith(`${GITNEXUS_DIR}/`);
}
function repoRelativeWatchPath(repoPath: string, candidate: string): string | null {
const relative = path.relative(repoPath, candidate).replace(/\\/g, '/');
if (!relative || relative.startsWith('../') || path.isAbsolute(relative)) return null;
return relative;
}
export interface WatchEnvironmentBaseline {
readonly maxFileSize: string | undefined;
readonly workerTimeout: string | undefined;
readonly verbose: string | undefined;
}
function setEnvironment(name: string, value: string | undefined): void {
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
function positiveInteger(
value: string | undefined,
flag: string,
maximum?: number,
): number | undefined {
if (value === undefined) return undefined;
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < 1)
throw new Error(`${flag} must be a positive integer`);
if (maximum !== undefined && parsed > maximum) {
throw new Error(`${flag} must not exceed ${maximum}`);
}
return parsed;
}
export async function resolveWatchOptions(
repoPath: string,
cli: WatchCliOptions,
baseline: WatchEnvironmentBaseline,
reportIgnoredConfig: (names: readonly string[]) => void = () => {},
): Promise<CoreAnalyzeOptions> {
const config = (await loadAnalyzeConfigStrict(repoPath)) ?? {};
const merged = mergeAnalyzeOptions(cli, config);
const unsupported = [
['--force', cli.force],
['--repair-fts', cli.repairFts],
['--embeddings', cli.embeddings],
['--drop-embeddings', cli.dropEmbeddings],
['--skills', cli.skills],
['--default-branch', cli.defaultBranch],
['--skip-agents-md', cli.skipAgentsMd],
['--skip-skills', cli.skipSkills],
['--no-stats', cli.stats === false],
['--self-commit', cli.selfCommit],
['--index-only', cli.indexOnly],
['--skip-git', cli.skipGit],
['--spring-actuator', cli.springActuator],
['walCheckpointThreshold', cli.walCheckpointThreshold],
['embeddingThreads', cli.embeddingThreads],
['embeddingBatchSize', cli.embeddingBatchSize],
['embeddingSubBatchSize', cli.embeddingSubBatchSize],
['embeddingDevice', cli.embeddingDevice],
['embeddingBaseUrl', cli.embeddingBaseUrl],
['embeddingModel', cli.embeddingModel],
['--embedding-auth-token', cli.embeddingAuthToken],
['--embedding-dims', cli.embeddingDims],
].filter(([, value]) => value !== undefined && value !== false);
if (unsupported.length > 0) {
throw new Error(
`analyze --watch does not support ${unsupported.map(([name]) => name).join(', ')}`,
);
}
reportIgnoredConfig(
[
['embeddings', config.embeddings],
['dropEmbeddings', config.dropEmbeddings],
['defaultBranch', config.defaultBranch],
['skipAgentsMd', config.skipAgentsMd !== undefined],
['skipSkills', config.skipSkills !== undefined],
['stats', config.stats !== undefined],
['springActuator', config.springActuator],
['walCheckpointThreshold', config.walCheckpointThreshold],
['embeddingThreads', config.embeddingThreads],
['embeddingBatchSize', config.embeddingBatchSize],
['embeddingSubBatchSize', config.embeddingSubBatchSize],
['embeddingDevice', config.embeddingDevice],
['embeddingBaseUrl', config.embeddingBaseUrl],
['embeddingModel', config.embeddingModel],
]
.filter(([, value]) => value !== undefined && value !== false)
.map(([name]) => String(name)),
);
const branch =
merged.branch === undefined ? undefined : validateBranchName(merged.branch, '--branch');
const workerPoolSize = positiveInteger(merged.workers, '--workers');
const workerTimeoutSeconds = positiveInteger(merged.workerTimeout, 'workerTimeout');
const maxFileSize = positiveInteger(merged.maxFileSize, 'maxFileSize', MAX_FILE_SIZE_KB);
setEnvironment(
'GITNEXUS_MAX_FILE_SIZE',
maxFileSize === undefined ? baseline.maxFileSize : String(maxFileSize),
);
if (workerTimeoutSeconds !== undefined) {
process.env.GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS = String(workerTimeoutSeconds * 1000);
} else {
setEnvironment('GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS', baseline.workerTimeout);
}
setEnvironment('GITNEXUS_VERBOSE', merged.verbose ? '1' : baseline.verbose);
return {
pdg: merged.pdg,
branch,
registryName: merged.name,
allowDuplicateName: merged.allowDuplicateName,
workerPoolSize,
fetchWrappers: merged.fetchWrappers,
skipAgentsMd: true,
skipSkills: true,
noStats: true,
atomicIncremental: process.platform !== 'win32',
};
}
function refreshSummary(
result: AnalyzeResult,
observedPaths: readonly string[],
durationMs: number,
lastSuccessfulRefreshAt: string,
): string {
const measured = result.incrementalStats;
const changed = measured?.changedFiles ?? (result.alreadyUpToDate ? 0 : observedPaths.length);
const reparsed =
measured?.reparsedFiles ??
(typeof result.pipelineResult?.reparsedFileCount === 'number'
? result.pipelineResult.reparsedFileCount
: 0);
const dependents = measured?.affectedDependents ?? 0;
const mode = measured?.writeMode ?? (result.alreadyUpToDate ? 'no-op' : 'full');
return (
`Refresh complete: ${changed} changed, ${reparsed} re-parsed, ` +
`${dependents} affected dependent(s), ${durationMs}ms, ${mode}; ` +
`last success ${lastSuccessfulRefreshAt}`
);
}
async function waitUntilReady(watcher: FSWatcher): Promise<void> {
await new Promise<void>((resolve, reject) => {
const ready = () => {
watcher.off('error', failed);
resolve();
};
const failed = (error: unknown) => {
watcher.off('ready', ready);
reject(error);
};
watcher.once('ready', ready);
watcher.once('error', failed);
});
}
export interface WatchFileLoop {
readonly waitForIdle: () => Promise<void>;
readonly close: () => Promise<void>;
}
class WatchControlReloadError extends Error {
constructor(cause: unknown) {
super(cause instanceof Error ? cause.message : String(cause), { cause });
this.name = 'WatchControlReloadError';
}
}
export function shouldStopAfterWatchRefreshFailure(
error: unknown,
paths: readonly string[],
): boolean {
return (
paths.length > 0 &&
!(error instanceof WatchControlReloadError) &&
analyzeFailureMayHaveMutatedLiveIndex(error)
);
}
/** Start the real filesystem watcher with bounded, serialized refreshes. */
export async function startWatchFileLoop(
repoPath: string,
debounceMs: number,
refresh: (paths: readonly string[]) => Promise<void>,
onError: WatchRefreshError,
onWatcherError: (error: unknown) => void = (error) => onError(error, []),
): Promise<WatchFileLoop> {
let ignorePath = await createWatchIgnorePredicate(repoPath);
let ignoreControlValid = true;
const queue = new WatchRefreshQueue(
async (paths) => {
if (paths.some(isIgnoreControlPath) || !ignoreControlValid) {
const retryingInvalidControls = !ignoreControlValid;
try {
ignorePath = await createWatchIgnorePredicate(repoPath);
ignoreControlValid = true;
watcher.add(repoPath);
} catch (error) {
ignoreControlValid = false;
throw new WatchControlReloadError(
retryingInvalidControls
? new Error(
'Ignore controls remain invalid; fix them before indexing more changes.',
{
cause: error,
},
)
: error,
);
}
}
await refresh(paths);
},
onError,
debounceMs,
{
maxWaitMs: Math.max(2_000, debounceMs * 10),
maxPendingPaths: 1_000,
holdEventsUntilInitialRefresh: true,
isPriorityPath: (filePath) => isIgnoreControlPath(filePath) || isConfigControlPath(filePath),
},
);
const watcher: FSWatcher = watch(repoPath, {
ignoreInitial: true,
atomic: true,
followSymlinks: false,
awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 20 },
ignored: (candidate, stats) => {
const relative = repoRelativeWatchPath(repoPath, candidate);
if (relative !== null && isAnalyzerOwnedWatchPath(relative)) return true;
if (relative !== null && (isIgnoreControlPath(relative) || isConfigControlPath(relative))) {
return false;
}
return ignorePath(candidate, stats?.isDirectory() ?? false);
},
});
watcher.on('all', (event, changedPath) => {
if (event !== 'add' && event !== 'change' && event !== 'unlink') return;
const relative = repoRelativeWatchPath(repoPath, changedPath);
if (relative && isRelevantWatchPath(relative) && !isAnalyzerOwnedWatchPath(relative)) {
queue.enqueue(relative);
}
});
watcher.on('error', (error) => {
// Chokidar can surface a transient EPERM on Windows while an ignored
// analyzer-owned path is replaced. Re-arm the root and force one bounded
// catch-up refresh so a missed event cannot leave the graph stale. Other
// watcher errors may mean coverage was lost and remain fatal.
if (TRANSIENT_WATCH_ERROR_CODES.has((error as NodeJS.ErrnoException).code ?? '')) {
watcher.add(repoPath);
queue.enqueue(WATCH_FULL_REFRESH_PATH);
return;
}
onWatcherError(error);
});
try {
await waitUntilReady(watcher);
await queue.runInitial();
} catch (error) {
await watcher.close();
await queue.close();
throw error;
}
return {
waitForIdle: () => queue.waitForIdle(),
close: async () => {
await watcher.close();
await queue.close();
},
};
}
export async function watchCommandWithRunnerIdentity(
runnerIdentityAtBootstrap: AnalyzerRunnerIdentity,
inputPath?: string,
cliOptions: WatchCliOptions = {},
): Promise<void> {
if (await ensureHeap({ cleanForwardedTermination: true })) return;
const requestedRepoPath = inputPath ? path.resolve(inputPath) : getGitRoot(process.cwd());
if (requestedRepoPath === null || !hasGitDir(requestedRepoPath)) {
cliError(' gitnexus analyze --watch requires a Git repository.');
process.exitCode = 1;
return;
}
const repoPath = await fs.realpath(requestedRepoPath);
const baselineEnvironment: WatchEnvironmentBaseline = {
maxFileSize: process.env.GITNEXUS_MAX_FILE_SIZE,
workerTimeout: process.env.GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS,
verbose: process.env.GITNEXUS_VERBOSE,
};
try {
let ignoredConfigSignature: string | undefined;
const reportIgnoredConfig = (names: readonly string[]) => {
const signature = [...names].sort().join(',');
if (signature === ignoredConfigSignature) return;
ignoredConfigSignature = signature;
if (names.length > 0) {
cliWarn(`Watch mode ignores unsupported .gitnexusrc settings: ${names.join(', ')}.`);
}
};
let debounceMs: number;
let analyzeOptions: CoreAnalyzeOptions;
try {
debounceMs =
positiveInteger(
cliOptions.debounce ?? String(DEFAULT_DEBOUNCE_MS),
'--debounce',
MAX_TIMER_DELAY_MS,
) ?? DEFAULT_DEBOUNCE_MS;
analyzeOptions = await resolveWatchOptions(
repoPath,
cliOptions,
baselineEnvironment,
reportIgnoredConfig,
);
} catch (error) {
cliError(` ${error instanceof Error ? error.message : String(error)}`);
process.exitCode = 1;
return;
}
let stopWatching!: () => void;
const stopped = new Promise<void>((resolve) => {
stopWatching = resolve;
});
const stop = () => stopWatching();
process.once('SIGINT', stop);
process.once('SIGTERM', stop);
try {
let loop: WatchFileLoop;
let fatalRefreshError: unknown;
let configControlValid = true;
let lastSuccessfulRefreshAt: string | undefined;
try {
loop = await startWatchFileLoop(
repoPath,
debounceMs,
async (paths) => {
if (paths.some(isConfigControlPath) || !configControlValid) {
const retryingInvalidConfig = !configControlValid;
try {
analyzeOptions = await resolveWatchOptions(
repoPath,
cliOptions,
baselineEnvironment,
reportIgnoredConfig,
);
configControlValid = true;
} catch (error) {
configControlValid = false;
throw new WatchControlReloadError(
retryingInvalidConfig
? new Error(
'Configuration remains invalid; fix it before indexing more changes.',
{
cause: error,
},
)
: error,
);
}
}
const startedAt = Date.now();
const result = await runFullAnalysis(
repoPath,
analyzeOptions,
{
onProgress: () => {},
onLog:
process.env.GITNEXUS_VERBOSE === '1'
? (message) => cliInfo(` ${message}`)
: undefined,
},
runnerIdentityAtBootstrap,
);
lastSuccessfulRefreshAt = new Date().toISOString();
if (paths.length === 0) {
cliInfo(
result.alreadyUpToDate
? `Watching ${repoPath}; index is up to date.`
: `Watching ${repoPath}; initial index ready in ${Date.now() - startedAt}ms.`,
);
} else {
cliInfo(
refreshSummary(result, paths, Date.now() - startedAt, lastSuccessfulRefreshAt),
);
}
},
(error, paths) => {
const detail = paths.length > 0 ? ` (${paths.length} queued path(s))` : '';
if (shouldStopAfterWatchRefreshFailure(error, paths)) {
fatalRefreshError = error;
cliError(
`Refresh failed${detail}: ${error instanceof Error ? error.message : String(error)}. ` +
'Watch mode is stopping because the live index may have been updated in place.',
);
stopWatching();
return;
}
const lastSuccess = lastSuccessfulRefreshAt ?? 'none yet';
cliWarn(
`Refresh failed${detail}: ${error instanceof Error ? error.message : String(error)}. ` +
`Retry scheduled; last success ${lastSuccess}.`,
);
},
(error) => {
fatalRefreshError = error;
cliError(
`Watcher failed: ${error instanceof Error ? error.message : String(error)}. ` +
'Watch mode is stopping.',
);
stopWatching();
},
);
} catch (error) {
cliError(
` Unable to start watcher: ${error instanceof Error ? error.message : String(error)}`,
);
process.exitCode = 1;
return;
}
await stopped;
await loop.close();
if (fatalRefreshError !== undefined) process.exitCode = 1;
} finally {
process.removeListener('SIGINT', stop);
process.removeListener('SIGTERM', stop);
}
} finally {
setEnvironment('GITNEXUS_MAX_FILE_SIZE', baselineEnvironment.maxFileSize);
setEnvironment('GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS', baselineEnvironment.workerTimeout);
setEnvironment('GITNEXUS_VERBOSE', baselineEnvironment.verbose);
}
}

View file

@ -363,6 +363,7 @@ interface RespawnExit {
stdout?: string;
stderr?: string;
message?: string;
forwardedSignal?: NodeJS.Signals;
}
const appendOutputTail = (tail: string, chunk: unknown): string => {
@ -395,17 +396,28 @@ const runRespawnedAnalyze = (
let stdout = '';
let stderr = '';
let settled = false;
const finish = (exit: RespawnExit): void => {
if (settled) return;
settled = true;
resolve(exit);
};
let forwardedSignal: NodeJS.Signals | undefined;
const child = spawn(process.execPath, [...args], {
stdio: ['inherit', 'pipe', 'pipe'],
windowsHide: true,
env,
});
const forwardSignal = (signal: NodeJS.Signals): void => {
forwardedSignal ??= signal;
if (child.exitCode === null && child.signalCode === null) child.kill(signal);
};
const forwardSigint = () => forwardSignal('SIGINT');
const forwardSigterm = () => forwardSignal('SIGTERM');
const finish = (exit: RespawnExit): void => {
if (settled) return;
settled = true;
process.removeListener('SIGINT', forwardSigint);
process.removeListener('SIGTERM', forwardSigterm);
resolve({ ...exit, forwardedSignal });
};
process.once('SIGINT', forwardSigint);
process.once('SIGTERM', forwardSigterm);
child.stdout?.on('data', (chunk) => {
stdout = appendOutputTail(stdout, chunk);
@ -548,7 +560,16 @@ export function parseMaxOldSpaceMb(nodeOptions: string): number | null {
* 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> {
export function forwardedSignalExitCode(signal: NodeJS.Signals, cleanTermination: boolean): number {
if (cleanTermination) return 0;
if (signal === 'SIGINT') return 130;
if (signal === 'SIGTERM') return 143;
return 1;
}
export async function ensureHeap(
options: { cleanForwardedTermination?: boolean } = {},
): 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
@ -590,6 +611,13 @@ async function ensureHeap(): Promise<boolean> {
};
if (shouldBridgeRespawnProgressTty()) childEnv[RESPAWN_PROGRESS_ENV] = '1';
const childExit = await runRespawnedAnalyze(childArgs, childEnv);
if (childExit.forwardedSignal !== undefined) {
process.exitCode = forwardedSignalExitCode(
childExit.forwardedSignal,
options.cleanForwardedTermination === true,
);
return true;
}
if (childExit.status !== 0 || childExit.signal) {
if (childProcessLikelyOom(childExit)) {
cliError(
@ -640,6 +668,8 @@ const ANALYZE_CLI_ENV_KEYS = [
'GITNEXUS_EMBEDDING_SUB_BATCH_SIZE',
'GITNEXUS_EMBEDDING_DEVICE',
'GITNEXUS_ANALYZE_PROGRESS_ACTIVE',
'GITNEXUS_ANALYZER_IDENTITY_IN_PROCESS_GUARDS',
'GITNEXUS_RESOLVE_DEF_GRAPH_ID_MEMO',
'GITNEXUS_EMBEDDING_URL',
'GITNEXUS_EMBEDDING_MODEL',
'GITNEXUS_EMBEDDING_API_KEY',
@ -740,6 +770,19 @@ export const analyzeCommandWithRunnerIdentity = async (
options?: AnalyzeOptions,
): Promise<void> => analyzeCommand(inputPath, options, runnerIdentityAtBootstrap);
export async function analyzeOrWatchCommandWithRunnerIdentity(
runnerIdentityAtBootstrap: AnalyzerRunnerIdentity,
inputPath?: string,
options: AnalyzeOptions = {},
): Promise<void> {
if (options.watch) {
const { watchCommandWithRunnerIdentity } = await import('./analyze-watch.js');
await watchCommandWithRunnerIdentity(runnerIdentityAtBootstrap, inputPath, options);
return;
}
await analyzeCommandWithRunnerIdentity(runnerIdentityAtBootstrap, inputPath, options);
}
const analyzeCommandImpl = async (
inputPath?: string,
cliOptions?: AnalyzeOptions,
@ -1331,6 +1374,7 @@ const analyzeCommandImpl = async (
// Extra fetch-wrapper names from `.gitnexusrc` (#1589/#1852 residual);
// forwarded to the routes phase consumer scan.
fetchWrappers: options.fetchWrappers,
springActuatorPath: options.springActuator,
// The CLI always process.exit()s after this returns (success path at the
// end of analyzeCommandImpl, error/interrupt paths via process.exit too),
// so the finalize close skips the native conn/db close — it can double-free
@ -1395,6 +1439,9 @@ const analyzeCommandImpl = async (
console.error = origError;
bar.stop();
console.log(' Already up to date\n');
if (runOptions.registryName) {
console.log(` Registry name: ${result.repoName}\n`);
}
if (baseRefRefreshed.length > 0) {
console.log(
` Updated base_ref to "${resolvedDefaultBranch}" in ${baseRefRefreshed.join(', ')}\n`,
@ -1486,6 +1533,7 @@ const analyzeCommandImpl = async (
// exercised on the `--skills` path by analyze-no-stats-bridge.test.ts.
noStats: options.stats === false,
hasPdg: options.pdg === true,
hasSpringActuator: options.springActuator !== undefined,
},
);
}

View file

@ -0,0 +1,125 @@
/** Remote auto-sync CLI (`gitnexus auto-sync`). Local incremental watch lives in `analyze-watch.ts`. */
import fs from 'node:fs/promises';
import path from 'node:path';
import {
getAutoSyncConfigPath,
getAutoSyncMutexPath,
readAutoSyncWatchStatus,
resetAutoSyncState,
startAutoSyncWatch,
stopAutoSyncWatch,
type WatchStatusRecord,
} from '../core/auto-sync/index.js';
export async function autoSyncCommand(action = 'start'): Promise<void> {
if (action === 'init') {
await initWatchConfig();
return;
}
if (action === 'reset') {
if (!(await resetAutoSyncState())) {
process.stderr.write(
`[auto-sync] Cannot reset analysis state while the watch mutex is held. Confirm no watch process is running, then remove ${getAutoSyncMutexPath()}.\n`,
);
process.exitCode = 1;
return;
}
process.stdout.write('[auto-sync] Reset analysis state.\n');
return;
}
if (action === 'status') {
printStatus(await readAutoSyncWatchStatus());
return;
}
if (action === 'stop') {
if ((await stopAutoSyncWatch()) !== 'stopped') process.exitCode = 1;
return;
}
if (action === 'restart') {
const result = await stopAutoSyncWatch();
if (result === 'refused' || result === 'timeout') {
process.exitCode = 1;
return;
}
await startWatchProcess();
return;
}
if (action !== 'start') {
process.stderr.write(`[auto-sync] Unknown auto-sync action: ${action}\n`);
process.exitCode = 1;
return;
}
await startWatchProcess();
}
async function startWatchProcess(): Promise<void> {
const handle = await startAutoSyncWatch();
if (!handle) {
process.exitCode = 1;
return;
}
const stop = () => {
void handle.stop().then(
() => {
process.stderr.write('[auto-sync] Watch stopped.\n');
process.exit(0);
},
(error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`[auto-sync] Failed to stop watch: ${message}\n`);
process.exit(1);
},
);
};
process.once('SIGINT', stop);
process.once('SIGTERM', stop);
}
function printStatus(status: WatchStatusRecord): void {
const parts = [`state=${status.state}`];
if (status.pid) parts.push(`pid=${status.pid}`);
if (status.configPath) parts.push(`config=${status.configPath}`);
if (status.message) parts.push(`message=${status.message}`);
parts.push(`updated_at=${status.updatedAt}`);
process.stdout.write(`${parts.join(' ')}\n`);
}
async function initWatchConfig(): Promise<void> {
const configPath = getAutoSyncConfigPath();
try {
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
defaultSyncConfig(path.resolve(path.dirname(configPath), 'repos')),
{
flag: 'wx',
},
);
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === 'EEXIST') {
process.stderr.write(`[auto-sync] Config already exists: ${configPath}\n`);
process.exitCode = 1;
return;
}
throw err;
}
process.stdout.write(`[auto-sync] Created ${configPath}\n`);
}
function defaultSyncConfig(localPath: string): string {
return [
'sync_interval_minutes: 10',
'max_concurrency: 1',
'repo_git_timeout: 10s',
'analyze_timeout: 5m',
'analyze_failure_threshold: 3',
'projects:',
` - local_path: ${localPath}`,
' branches: [master, main]',
' overwrite_local_changes: false',
' remote_urls:',
' - git@github.com:owner/repo.git',
'',
].join('\n');
}

View file

@ -6,6 +6,7 @@ type DetectChangesSummary = {
changed_count?: number;
affected_count?: number;
risk_level?: string;
message?: string;
};
type ChangedSymbol = {
@ -55,6 +56,29 @@ export function formatDetectChangesResult(result: unknown): string {
);
if ((summary.changed_count ?? 0) === 0) {
// Parse-fail payloads set `partial` and an honest `message` (#2915/#3131).
// Production *clean* trees also set English `message: 'No changes detected.'`
// — that must go through `t('tool.detectChanges.noChanges')` or zh-CN never
// fires. Only pass the backend string through on a degraded/parse-fail run.
if (
payload.partial &&
typeof summary.message === 'string' &&
summary.message.trim().length > 0
) {
return [...notes, summary.message.trim()].join('\n');
}
// Confirmed no-overlap: files parsed, mapping succeeded, zero symbols.
// `queryDegraded` is `partial: true` with the same counts and no message —
// do not call that a confirmed mapping (#3131 honesty).
if (!payload.partial && (summary.changed_files ?? 0) > 0) {
return [
...notes,
t('tool.detectChanges.noOverlappingSymbols', { files: summary.changed_files }),
].join('\n');
}
if (payload.partial) {
return notes.join('\n');
}
return [...notes, t('tool.detectChanges.noChanges')].join('\n');
}

View file

@ -302,6 +302,20 @@ function formatTruncationSuffix(result: {
return label ? ` (by ${label})` : '';
}
function pushCallgraphRiskLines(lines: string[], result: any): void {
if (result.risk) {
lines.push(`Risk: ${result.risk}`);
}
if (result.riskNote) {
lines.push(String(result.riskNote));
}
if (result.riskScale?.comparableAcrossKinds === false && result.riskSharedAxes) {
lines.push(
`Shared-axes risk: ${result.riskSharedAxes} (process/module axes are unavailable — compare File vs symbol only; do not use this to waive a HIGH/CRITICAL risk warning)`,
);
}
}
export function formatImpactResult(result: any): string {
if (result.error) {
const suggestion = result.suggestion ? `\nSuggestion: ${result.suggestion}` : '';
@ -567,14 +581,21 @@ export function formatImpactResult(result: any): string {
// #1858 — "isolated" is a confident claim. If an interface / indirection
// boundary is on the path, the true count is a lower bound, not zero;
// callers binding via DI / dynamic dispatch were not traced. Say so instead.
const lines: string[] = [];
if (result.epistemic === 'lower-bound') {
const lines = [
lines.push(
`${target?.name || '?'}: no direct ${direction} dependencies traced, but this is a LOWER BOUND — unresolved indirection on the path (actual impact may be higher):`,
];
);
for (const b of result.boundaries || []) lines.push(`${b}`);
return lines.join('\n');
} else if (direction === 'upstream') {
lines.push(
`${target?.name || '?'}: No ${direction} callers resolved. This is not evidence the symbol is unused or isolated.`,
);
} else {
lines.push(`${target?.name || '?'}: No ${direction} dependencies found.`);
}
return `${target?.name || '?'}: No ${direction} dependencies found. This symbol appears isolated.`;
pushCallgraphRiskLines(lines, result);
return lines.join('\n');
}
const lines: string[] = [];
@ -594,6 +615,7 @@ export function formatImpactResult(result: any): string {
);
for (const b of result.boundaries || []) lines.push(`${b}`);
}
pushCallgraphRiskLines(lines, result);
lines.push('');
const depthLabels: Record<number, string> = {

View file

@ -1,6 +1,8 @@
// gitnexus/src/cli/group.ts
import { createRequire } from 'node:module';
import type { Command } from 'commander';
import type { RegistryWriteOutcome } from '../core/group/sync.js';
import type { MatchType } from '../core/group/types.js';
import { logger } from '../core/logger.js';
const _require = createRequire(import.meta.url);
@ -120,16 +122,43 @@ export function registerGroupCommands(program: Command): void {
indexStale: boolean;
contractsStale: boolean;
missing: boolean;
/**
* Optional here on purpose: a payload produced before the split
* carries no such key, and an absent one must degrade to the
* label this command has always printed rather than to the new
* one an unrecorded cause is not evidence of a cause.
*/
unresolvable?: boolean;
unresolvableReason?: string;
commitsBehind?: number;
}
>;
missingRepos?: string[];
unreadableRepos?: string[];
suppressedMatchStages?: string[];
};
console.log(' Repo index / contracts staleness:');
for (const [repoPath, row] of Object.entries(st.repos || {})) {
if (row.missing) {
console.log(` ${repoPath.padEnd(25)} MISSING (not in registry or unreadable)`);
// Two different facts with two different remedies: a repo the
// registry never heard of is fixed by indexing it, while an entry
// the resolver choked on is fixed by repairing the registry.
// Printing "no entry in the registry" for the second one states a
// cause that was never measured, and points at the wrong repair.
if (row.unresolvable) {
// The reason can be multi-line — an ambiguous registry names
// every colliding clone. Fold it onto this row's line rather
// than truncating it: those paths are what the operator acts on,
// and a table row that swallows half its own explanation is the
// failure this label exists to stop.
const why = (row.unresolvableReason ?? 'the registry entry could not be resolved')
.replace(/\s+/g, ' ')
.trim();
console.log(` ${repoPath.padEnd(25)} UNRESOLVABLE (${why})`);
continue;
}
console.log(` ${repoPath.padEnd(25)} MISSING (no entry in the registry)`);
continue;
}
const idx = row.indexStale
@ -138,9 +167,41 @@ export function registerGroupCommands(program: Command): void {
const ctr = row.contractsStale ? ' CONTRACTS_STALE' : '';
console.log(` ${repoPath.padEnd(25)} ${idx}${ctr}`);
}
// `undefined` and `[]` are different answers here: a registry written
// before this was tracked has no opinion, while an empty array is a
// measurement. Printing nothing for both would let an unmeasured sync
// read as evidence that every index opened cleanly.
//
// `undefined` covers two ways of not knowing — the field is absent, or
// it held something that was not a list of repo paths and `getStatus`
// declined to guess. Naming only the first would make a corrupt
// registry read as a merely old one, which is the same shape of wrong
// answer this command exists to stop giving.
const unreadable = st.unreadableRepos;
if (unreadable === undefined) {
console.log(
`\n Last sync unreadable repos: not recorded` +
`\n (the registry predates this field, or its value could not be read)` +
`\n Re-run \`gitnexus group sync\` to record it.`,
);
} else if (unreadable.length > 0) {
console.log(`\n Last sync unreadable repos: ${unreadable.join(', ')}`);
}
if ((st.missingRepos || []).length > 0) {
console.log(`\n Last sync missing repos: ${st.missingRepos!.join(', ')}`);
}
// Only the populated case prints. Absent means a registry that predates
// the field, and empty is the ordinary clean sync — neither is worth a
// line, whereas a narrowed registry changes how every later answer
// should be read.
const skippedStages = st.suppressedMatchStages ?? [];
if (skippedStages.length > 0) {
console.log(
`\n Last sync skipped matching stages: ${skippedStages.join(', ')}` +
`\n Cross-links those stages would have found are absent by request.` +
`\n Re-run \`gitnexus group sync\` without --exact-only for the complete set.`,
);
}
} finally {
await backend.dispose().catch(() => {});
}
@ -149,39 +210,137 @@ export function registerGroupCommands(program: Command): void {
group
.command('sync <name>')
.description('Sync Contract Registry — extract contracts and build cross-links')
.option('--skip-embeddings', 'Exact + BM25 only (no embedding fallback)')
.option('--exact-only', 'Exact match only')
.option('--allow-stale', 'Skip stale index warnings')
.option('--verbose', 'Show each cross-link detail')
.option(
'--exact-only',
'Skip wildcard service matching; cross-link on exact contract-id match only (manifest links still apply)',
)
.option('--verbose', 'Show additional sync diagnostics')
.option('--json', 'JSON output')
.action(async (name: string, opts: Record<string, boolean | undefined>) => {
const { getGroupDir, getDefaultGitnexusDir } = await import('../core/group/storage.js');
const { loadGroupConfig } = await import('../core/group/config-parser.js');
const { syncGroup } = await import('../core/group/sync.js');
const { syncGroup, formatGroupSyncAmbiguousError } = await import('../core/group/sync.js');
const { GroupSyncLockError } = await import('../core/group/group-lock.js');
const { RegistryAmbiguousTargetError } = await import('../storage/repo-manager.js');
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
const config = await loadGroupConfig(groupDir);
console.log(`Syncing group "${name}" (${Object.keys(config.repos).length} repos)...\n`);
const result = await syncGroup(config, {
groupDir,
allowStale: Boolean(opts.allowStale),
verbose: Boolean(opts.verbose),
skipEmbeddings: Boolean(opts.skipEmbeddings),
exactOnly: Boolean(opts.exactOnly),
});
let result: Awaited<ReturnType<typeof syncGroup>>;
try {
result = await syncGroup(config, {
groupDir,
verbose: Boolean(opts.verbose),
exactOnly: Boolean(opts.exactOnly),
});
} catch (err) {
if (err instanceof RegistryAmbiguousTargetError) {
logger.error(`⚠️ Did not sync group "${name}": ${formatGroupSyncAmbiguousError(err)}`);
process.exitCode = 1;
return;
}
// A sync that could not take the group's lock did NOT run and wrote
// nothing (R9 fails closed). That is an operator-actionable outcome, not
// a crash, so report it as a failed command rather than letting it
// surface as an unhandled rejection with a stack trace — commander's
// async actions have no error handler, so an uncaught throw here would
// print exactly that.
if (!(err instanceof GroupSyncLockError)) throw err;
logger.error(`⚠️ Did not sync group "${name}": ${err.message}`);
process.exitCode = 1;
return;
}
if (opts.json) {
console.log(JSON.stringify(result, null, 2));
} else {
console.log(`\nMatching cascade:`);
const exactLinks = result.crossLinks.filter((l) => l.matchType === 'exact');
console.log(` exact: ${exactLinks.length} cross-links (confidence 1.0)`);
console.log(` unmatched: ${result.unmatched.length} contracts`);
console.log(
`\nWrote contracts.json (${result.contracts.length} contracts, ${result.crossLinks.length} cross-links)`,
);
// Repos we could not read are the most likely explanation for a small
// or empty contract count, so they are reported before the counts —
// otherwise a run that read nothing looks exactly like a clean run.
if (result.unreadableRepos.length > 0) {
// No "re-run with GITNEXUS_LOG_LEVEL=warn" hint: the default level is
// `info`, and pino emits `warn` (40) at `info` (30), so the reason was
// already printed by this same run — raising the level to `warn` would
// only suppress the surrounding `info` output.
console.log(
`\n ⚠️ Could not extract contracts from: ${result.unreadableRepos.join(', ')}` +
`\n None of their contracts are included in this sync (the warning above says why),` +
`\n or check \`gitnexus doctor\` in the affected repo.`,
);
}
if (result.missingRepos.length > 0) {
console.log(
`\n ⚠️ Not found in the registry: ${result.missingRepos.join(', ')}` +
`\n Index them with \`gitnexus analyze\`, or remove them from group.yaml.`,
);
}
// Every stage that produced a link, not just `exact`. This used to print
// `Matching cascade:` and then count `exact` alone, while the `Wrote
// contracts.json (…)` line below reports `result.crossLinks.length` —
// which also includes `manifest` and `wildcard` links. For any group with
// those, the two numbers disagreed with nothing on screen explaining why.
// Summing the stages here makes them reconcile by construction.
console.log(`\nMatching:`);
// Exhaustive by construction, same idiom as OUTCOME_LINE below: adding a
// MatchType fails the build here instead of silently going uncounted and
// reopening the very mismatch this replaced. Every stage prints even at
// zero — a stage that is absent reads as "did not apply", not "found none".
const STAGE_COUNTS: Record<MatchType, number> = {
exact: 0,
manifest: 0,
wildcard: 0,
};
for (const link of result.crossLinks) STAGE_COUNTS[link.matchType] += 1;
// A stage the sync was told to skip is reported as skipped, not as a
// zero count. The two are different facts — "ran, matched nothing" and
// "never ran" — and printing both as `0` is the same conflation this
// block replaced. Driven by what the sync did (`suppressedMatchStages`)
// rather than by what the caller asked for, so it stays correct on the
// outcomes where the run ended without writing a registry.
for (const stage of Object.keys(STAGE_COUNTS) as MatchType[]) {
const count = STAGE_COUNTS[stage];
const label = `${stage}:`.padEnd(10);
if (result.suppressedMatchStages.includes(stage)) {
console.log(` ${label} skipped (--exact-only)`);
continue;
}
const confidence = stage === 'exact' ? ' (confidence 1.0)' : '';
console.log(` ${label} ${count} cross-links${confidence}`);
}
console.log(` ${'unmatched:'.padEnd(10)} ${result.unmatched.length} contracts`);
// Driven by what actually happened to the file. This line used to be
// unconditional, so a run that deliberately preserved the previous
// registry still announced `Wrote contracts.json (0 contracts, 0
// cross-links)` — a confident false statement about persisted state, on
// the exact path this command exists to make legible.
// Exhaustive by construction: a `Record` keyed on the union means a
// new outcome fails the build here instead of printing nothing, which
// is what previously pushed a distinct state into `preserved` and made
// this summary false on one of the two branches it then covered.
const OUTCOME_LINE: Record<RegistryWriteOutcome, string | null> = {
written:
`\nWrote contracts.json (${result.contracts.length} contracts, ` +
`${result.crossLinks.length} cross-links)`,
preserved:
`\nKept the previous contracts.json — no repo in this group could be read.` +
`\n Its contracts and cross-links are unchanged; only the unreadable/missing` +
`\n repo lists were refreshed to describe THIS run. Fix the repos above and re-run.`,
superseded:
`\nDid NOT touch contracts.json — no repo in this group could be read, and another` +
`\n sync replaced the file while this one waited for the group lock. That sync's` +
`\n result stands and this run's repo lists were NOT recorded: they describe a` +
`\n group state older than what is on disk. Fix the repos above and re-run.`,
'no-prior-registry':
`\nDid NOT write contracts.json — no repo in this group could be read,` +
`\n and there is no previous contracts.json to fall back on. Fix the repos` +
`\n above and re-run.`,
// Nothing to say: the caller asked for no write.
'not-attempted': null,
};
const line = OUTCOME_LINE[result.registryOutcome];
if (line) console.log(line);
}
});
@ -281,11 +440,28 @@ export function registerGroupCommands(program: Command): void {
// repos — reporting it as crossings understates a fan-out cap the
// same way #2787's totals did.
const dropped = (raw as { truncatedRepos?: string[] })?.truncatedRepos ?? [];
console.log(
dropped.length > 0
? ` risk is a LOWER BOUND — fan-out stopped early; crossings to ${dropped.length} repo(s) not traversed: ${dropped.join(', ')}`
: ' risk is a LOWER BOUND — the local impact walk did not complete (every bridge crossing was traversed)',
);
const reason = (raw as { truncationReason?: string })?.truncationReason;
// Keyed on the REASON, not on which incidental fact happens to be
// non-empty. `truncatedRepos` is populated for a structural gap too
// — the bridge's incomplete repos are unioned into it even when ZERO
// crossings were attempted — so branching on its length first
// reported "fan-out stopped early" for a run where nothing stopped
// early, and omitted the only remedy that works. Same false-cause
// shape the contract listing was just re-gated for, one command over.
const floorReason = (): string => {
if (reason === 'suppressed-stage') {
return 'the last sync skipped a matching stage (--exact-only); re-run `gitnexus group sync` without it for the complete graph';
}
if (reason === 'incomplete-sync') {
return dropped.length > 0
? `the last sync could not account for ${dropped.join(', ')}; their contracts are absent from every query against this bridge — re-run \`gitnexus group sync\``
: 'the last sync could not say which repos it read — re-run `gitnexus group sync`';
}
return dropped.length > 0
? `fan-out stopped early; crossings to ${dropped.length} repo(s) not traversed: ${dropped.join(', ')}`
: 'the local impact walk did not complete (every bridge crossing was traversed)';
};
console.log(` risk is a LOWER BOUND — ${floorReason()}`);
}
}
} finally {
@ -370,7 +546,15 @@ export function registerGroupCommands(program: Command): void {
return;
}
const { contracts, crossLinks } = raw as {
const {
contracts,
crossLinks,
truncated,
unreadableRepos,
missingRepos,
suppressedMatchStages,
truncationReason,
} = raw as {
contracts: Array<{
role: string;
contractId: string;
@ -384,10 +568,21 @@ export function registerGroupCommands(program: Command): void {
confidence: number;
contractId: string;
}>;
truncated?: boolean;
suppressedMatchStages?: string[];
truncationReason?: string;
unreadableRepos?: string[];
missingRepos?: string[];
};
if (opts.json) {
console.log(JSON.stringify({ contracts, crossLinks }, null, 2));
// The whole payload, not a re-serialized subset. Destructuring the two
// fields this command happens to print and rebuilding an object from
// them dropped everything else the service returned — which is how the
// completeness fields were invisible here while the MCP tool carried
// them. Printing `raw` means a field added to the service reaches
// `--json` without a matching edit in this file.
console.log(JSON.stringify(raw, null, 2));
} else {
console.log(`Contracts (${contracts.length}):`);
for (const c of contracts) {
@ -399,6 +594,39 @@ export function registerGroupCommands(program: Command): void {
` ${l.from.repo} -> ${l.to.repo} [${l.matchType}, conf=${l.confidence}] ${l.contractId}`,
);
}
// Separate from `truncated` below, and deliberately so: that one means
// the sync could not read something and the remedy is to fix the repo.
// This one means the sync was ASKED to skip a stage, and the remedy is
// to re-run without the flag. A listing narrowed on purpose is still
// narrowed, and without this the human view showed nothing at all.
if (suppressedMatchStages && suppressedMatchStages.length > 0) {
console.log(
`\n⚠ This listing is a lower bound: the last sync skipped ${suppressedMatchStages.join(', ')} matching` +
`\n (--exact-only), so cross-links that stage would have found are absent.` +
`\n Re-run \`gitnexus group sync\` without --exact-only for the complete set.`,
);
}
// Gated on the REASON, not just the flag. A suppressed stage sets
// `truncated` with both repo lists empty, which sent this block down
// its else-branch and printed "the last sync did not record which
// repos it could read" — a false statement, with the wrong remedy,
// about a sync that recorded them fine. The suppressed-stage warning
// above already said the true thing. When a repo gap co-occurs the
// reason is 'incomplete-sync' (the repo side takes precedence in
// `crossRepoCompleteness`), so this block still runs for it.
if (truncated && truncationReason !== 'suppressed-stage') {
// Counts above are a floor, not a census. Name the repos when the
// registry recorded them, and say so plainly when it did not — a
// listing that cannot say what it is missing is still incomplete.
const absent = [...(unreadableRepos ?? []), ...(missingRepos ?? [])];
console.log(
absent.length > 0
? `\n⚠ This listing is incomplete: the last sync could not account for ${absent.join(', ')}.` +
`\n Contracts from those repos are absent, so the counts above are a lower bound.`
: `\n⚠ This listing is incomplete: the last sync did not record which repos it could` +
`\n read, so the counts above are a lower bound. Re-run group sync.`,
);
}
}
} finally {
await backend.dispose().catch(() => {});

View file

@ -13,6 +13,8 @@ const COMMAND_DESCRIPTION_KEYS = {
'': 'help.description.root',
setup: 'help.command.setup.description',
uninstall: 'help.command.uninstall.description',
watch: 'help.command.watch.description',
'auto-sync': 'help.command.autoSync.description',
analyze: 'help.command.analyze.description',
index: 'help.command.index.description',
serve: 'help.command.serve.description',
@ -72,6 +74,8 @@ const OPTION_DESCRIPTION_KEYS = {
'analyze|--embedding-batch-size <n>': 'help.option.analyze.embeddingBatchSize',
'analyze|--embedding-sub-batch-size <n>': 'help.option.analyze.embeddingSubBatchSize',
'analyze|--embedding-device <device>': 'help.option.analyze.embeddingDevice',
'analyze|--watch': 'help.option.analyze.watch',
'analyze|--debounce <ms>': 'help.option.analyze.debounce',
'index|-f, --force': 'help.option.index.force',
'index|--allow-non-git': 'help.option.index.allowNonGit',
'mcp|--http': 'help.option.mcp.http',
@ -155,9 +159,7 @@ const OPTION_DESCRIPTION_KEYS = {
'embeddings install|--cuda': 'help.option.embeddings.install.cuda',
'embeddings install|--force': 'help.option.embeddings.install.force',
'group create|--force': 'help.option.group.create.force',
'group sync|--skip-embeddings': 'help.option.group.sync.skipEmbeddings',
'group sync|--exact-only': 'help.option.group.sync.exactOnly',
'group sync|--allow-stale': 'help.option.group.sync.allowStale',
'group sync|--verbose': 'help.option.group.sync.verbose',
'group sync|--json': 'help.option.json',
'group impact|--target <symbol>': 'help.option.group.impact.target',

View file

@ -33,6 +33,17 @@ export const en = {
'status.workspaceIndexLabel':
"Workspace index: last analyzed on '{{primary}}' (re-run gitnexus analyze to follow the current branch)",
'status.status': 'Status',
'status.indexContentCurrent': 'Index content: matches all {{count}} covered file(s)',
'status.indexContentDrifted':
'Index content: {{changed}} changed, {{added}} added, {{deleted}} deleted',
'status.indexContentMore': ' ...and {{count}} more {{label}}',
'status.indexContentUnmeasurable':
'Index content: not comparable ({{reason}}); fell back to the working-tree check',
'status.indexContentScanFailed':
'Index content: coverage scan failed; treating the index as stale',
'status.driftChanged': 'changed',
'status.driftAdded': 'added',
'status.driftDeleted': 'deleted',
'status.upToDate': '✅ up-to-date',
'status.stale': '⚠️ stale (re-run gitnexus analyze)',
'clean.deleteAll': 'This will delete GitNexus indexes for {{count}} repo(s):',
@ -65,6 +76,8 @@ export const en = {
'tool.warn.unknownKind':
"--kind '{{kind}}' is not a known symbol kind (e.g. Function, Class, Method); it will not narrow the result.",
'tool.detectChanges.noChanges': 'No changes detected.',
'tool.detectChanges.noOverlappingSymbols':
'Diff touched {{files}} file(s) but no indexed symbols overlap those hunks — not a clean tree.',
'tool.detectChanges.partial':
'PARTIAL RESULT: a graph query failed, so changed symbols may be missing. Do not read this as a clean pre-commit check.',
'tool.detectChanges.truncated':
@ -132,6 +145,16 @@ export const en = {
'One-time setup: configure MCP for Cursor, Claude Code, Antigravity, OpenCode, CodeBuddy, Qoder, Codex',
'help.command.uninstall.description':
'Reverse `setup`: remove GitNexus MCP entries, skills, and hooks from all detected editors',
'help.command.autoSync.description':
'Control scheduled repository clone/pull and analysis from GITNEXUS_HOME/watch_config.yml',
'help.autoSync.details':
'\nActions: init, start (default), restart, stop, status, reset\nConfiguration: GITNEXUS_HOME/watch_config.yml\nRuntime files: GITNEXUS_HOME/watch/watch.pid, watch.mutex, watch.owner.json, watch.status.json, auto-sync-state.json\nRecovery: mutexes with verified dead owners are reclaimed automatically; invalid or legacy mutexes fail closed and require manual removal after confirming no watch process is running.\nWrites: GITNEXUS_HOME/watch/project_commit_info.txt\nRemote URLs: only SSH URLs on github.com, gitlab.com, and gitee.com are allowed.\nRuns once immediately, then repeats on sync_interval_minutes.',
'help.command.watch.description':
'Ambiguous: use `analyze --watch` for local files, or `auto-sync` for scheduled remotes',
'help.watch.details':
'\n`gitnexus watch` does not start a watcher.\n Local working-tree incremental index: gitnexus analyze --watch\n Scheduled remote clone/pull + analyze: gitnexus auto-sync start\n',
'error.watch.ambiguous':
'`gitnexus watch` is ambiguous.\n Local working-tree incremental index: gitnexus analyze --watch\n Scheduled remote clone/pull + analyze: gitnexus auto-sync start\n',
'help.command.analyze.description': 'Index a repository (full analysis)',
'help.command.index.description':
'Register an existing .gitnexus/ folder into the global registry (no re-analysis needed)',
@ -190,7 +213,7 @@ export const en = {
'help.option.analyze.skills':
'Generate repo-specific skill files from detected communities (no-op when --index-only is also set).',
'help.option.analyze.skipAgentsMd':
'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md',
'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md. Does not skip standard skills in .claude/skills or .agents/skills; use --skip-skills for those. Community skills from --skills are unaffected.',
'help.option.analyze.noStats': 'Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md',
'help.option.analyze.selfCommit':
'Auto-commit AGENTS.md/CLAUDE.md changes after analyze (opt-in, off by default). Scoped to only those two files (never `git add -A`); no-ops if neither exists, neither changed, or the repo has no git identity configured.',
@ -217,6 +240,8 @@ export const en = {
'help.option.analyze.embeddingBatchSize': 'Number of nodes per embedding batch',
'help.option.analyze.embeddingSubBatchSize': 'Number of chunks per embedding model call',
'help.option.analyze.embeddingDevice': 'Embedding device: auto, cpu, dml, cuda, or wasm',
'help.option.analyze.watch': 'Keep the index current with serialized incremental refreshes',
'help.option.analyze.debounce': 'Watch quiet period before refreshing (milliseconds)',
'help.option.index.force': 'Register even if index metadata is missing (stats will be empty)',
'help.option.index.allowNonGit': 'Allow registering folders that are not Git repositories',
'help.option.port': 'Port number',
@ -225,7 +250,7 @@ export const en = {
'help.option.mcp.host':
'HTTP bind address (only with --http). Default: 127.0.0.1 (loopback). Use 0.0.0.0 to expose to all interfaces.',
'help.option.mcp.authToken':
'Require this bearer token in the Authorization header (only with --http); may also be set via the GITNEXUS_MCP_AUTH_TOKEN env var. Required for a non-loopback bind (--host 0.0.0.0/::), which otherwise refuses to start.',
"Require this bearer token in the Authorization header (only with --http); may also be set via the GITNEXUS_MCP_AUTH_TOKEN env var, which also enables MCP Bearer auth on gitnexus serve's /api/mcp route. Required for a non-loopback bind (--host 0.0.0.0/::), which otherwise refuses to start.",
'help.option.force.confirmation': 'Skip confirmation prompt',
'help.option.uninstall.force': 'Apply the changes (default is a dry-run preview)',
'help.option.clean.all': 'Clean all indexed repos',
@ -234,7 +259,7 @@ export const en = {
'Clean parked LadybugDB recovery sidecars (missing-shadow WAL quarantines and dirty-recovery parks)',
'help.option.wiki.force': 'Force full regeneration even if up to date',
'help.option.wiki.provider':
'LLM provider: minimax, openai, openrouter, azure, custom, cursor, claude, codex, or opencode (default: minimax)',
'LLM provider: minimax, openai, openrouter, azure, custom, cursor, claude, codex, opencode, or grok (default: minimax)',
'help.option.wiki.model': 'LLM model or deployment name (default: MiniMax-M3)',
'help.option.wiki.baseUrl':
'LLM API base URL. Azure v1: https://{resource}.openai.azure.com/openai/v1',
@ -293,10 +318,9 @@ export const en = {
'help.option.embeddings.install.force':
'Install into the runtime prefix even when the stack already resolves',
'help.option.group.create.force': 'Overwrite existing group',
'help.option.group.sync.skipEmbeddings': 'Exact + BM25 only (no embedding fallback)',
'help.option.group.sync.exactOnly': 'Exact match only',
'help.option.group.sync.allowStale': 'Skip stale index warnings',
'help.option.group.sync.verbose': 'Show each cross-link detail',
'help.option.group.sync.exactOnly':
'Skip wildcard service matching; cross-link on exact contract-id match only (manifest links still apply)',
'help.option.group.sync.verbose': 'Show additional sync diagnostics',
'help.option.status.json': 'Emit machine-readable index and analyzer provenance',
'help.option.json': 'JSON output',
'help.option.group.impact.target': 'Symbol or file name to analyze',

View file

@ -37,6 +37,15 @@ export const zhCN = {
'status.workspaceIndexLabel':
"工作区索引:最近在 '{{primary}}' 分支上分析(重新运行 gitnexus analyze 以跟随当前分支)",
'status.status': '状态',
'status.indexContentCurrent': '索引内容:与覆盖的全部 {{count}} 个文件一致',
'status.indexContentDrifted':
'索引内容:{{changed}} 个已修改,{{added}} 个新增,{{deleted}} 个已删除',
'status.indexContentMore': ' ……另有 {{count}} 个 {{label}}',
'status.indexContentUnmeasurable': '索引内容:无法比对({{reason}}),已回退到工作区检查',
'status.indexContentScanFailed': '索引内容:覆盖扫描失败,按过期处理',
'status.driftChanged': '已修改',
'status.driftAdded': '新增',
'status.driftDeleted': '已删除',
'status.upToDate': '✅ 已是最新',
'status.stale': '⚠️ 已过期(重新运行 gitnexus analyze',
'clean.deleteAll': '将删除 {{count}} 个仓库的 GitNexus 索引:',
@ -69,6 +78,8 @@ export const zhCN = {
'tool.warn.unknownKind':
"--kind '{{kind}}' 不是已知的符号类型(如 Function、Class、Method不会用于缩小结果范围。",
'tool.detectChanges.noChanges': '未检测到变更。',
'tool.detectChanges.noOverlappingSymbols':
'diff 触及 {{files}} 个文件,但没有索引符号与这些 hunk 重叠 — 并非干净工作区。',
'tool.detectChanges.partial':
'结果不完整:图查询失败,可能遗漏已变更符号。请勿将其视为通过的提交前检查。',
'tool.detectChanges.truncated':
@ -133,6 +144,16 @@ export const zhCN = {
'一次性设置:为 Cursor、Claude Code、Antigravity、OpenCode、CodeBuddy、Qoder、Codex 配置 MCP',
'help.command.uninstall.description':
'撤销 `setup`:从所有检测到的编辑器中移除 GitNexus 的 MCP 配置、技能和钩子',
'help.command.autoSync.description':
'控制基于 GITNEXUS_HOME/watch_config.yml 的定时 clone/pull 和分析',
'help.autoSync.details':
'\n操作init、start默认、restart、stop、status、reset\n配置GITNEXUS_HOME/watch_config.yml\n运行时文件GITNEXUS_HOME/watch/watch.pid、watch.mutex、watch.owner.json、watch.status.json、auto-sync-state.json\n恢复已验证 owner 退出的 mutex 会自动回收;无效或旧版 mutex 会安全拒绝,确认没有 watch 进程运行后再手动删除。\n写入GITNEXUS_HOME/watch/project_commit_info.txt\n远程地址仅允许 github.com、gitlab.com 和 gitee.com 上的 SSH 地址。\n启动后立即运行一次之后按 sync_interval_minutes 重复。',
'help.command.watch.description':
'含义不明确:本地文件请用 `analyze --watch`,定时远程同步请用 `auto-sync`',
'help.watch.details':
'\n`gitnexus watch` 不会启动监视器。\n 本地工作区增量索引gitnexus analyze --watch\n 定时远程 clone/pull 并分析gitnexus auto-sync start\n',
'error.watch.ambiguous':
'`gitnexus watch` 含义不明确。\n 本地工作区增量索引gitnexus analyze --watch\n 定时远程 clone/pull 并分析gitnexus auto-sync start\n',
'help.command.analyze.description': '索引仓库(完整分析)',
'help.command.index.description': '将现有 .gitnexus/ 文件夹注册到全局注册表(无需重新分析)',
'help.command.serve.description': '启动供 Web UI 连接的本地 HTTP 服务器',
@ -179,7 +200,8 @@ export const zhCN = {
'重建时删除现有嵌入。默认情况下,未传 `--embeddings` 的 `analyze` 会保留索引中已有嵌入。',
'help.option.analyze.skills':
'根据检测到的社区生成仓库专属 skill 文件(同时设置 --index-only 时无效)。',
'help.option.analyze.skipAgentsMd': '跳过更新 AGENTS.md 和 CLAUDE.md 中的 gitnexus 区块',
'help.option.analyze.skipAgentsMd':
'跳过更新 AGENTS.md 和 CLAUDE.md 中的 gitnexus 区块。不会跳过 .claude/skills 或 .agents/skills 下的标准 skill如需跳过那些请使用 --skip-skills。--skills 生成的社区 skill 不受影响。',
'help.option.analyze.noStats': '从 AGENTS.md 和 CLAUDE.md 中省略易变的文件/符号计数',
'help.option.analyze.selfCommit':
'在 analyze 后自动提交 AGENTS.md/CLAUDE.md 的变更(默认关闭,需显式开启)。仅限这两个文件(绝不使用 `git add -A`);若两者均不存在、均未变更,或仓库未配置 git 身份,则不执行任何操作。',
@ -203,6 +225,8 @@ export const zhCN = {
'help.option.analyze.embeddingBatchSize': '每个嵌入批次的节点数',
'help.option.analyze.embeddingSubBatchSize': '每次嵌入模型调用的分块数',
'help.option.analyze.embeddingDevice': '嵌入设备auto、cpu、dml、cuda 或 wasm',
'help.option.analyze.watch': '监视本地源文件变更并串行执行增量刷新',
'help.option.analyze.debounce': '刷新前的静默等待时间(毫秒)',
'help.option.index.force': '即使缺少索引元数据也注册(统计为空)',
'help.option.index.allowNonGit': '允许注册非 Git 仓库文件夹',
'help.option.port': '端口号',
@ -211,7 +235,7 @@ export const zhCN = {
'help.option.mcp.host':
'HTTP 绑定地址(仅与 --http 搭配使用。默认127.0.0.1(回环)。使用 0.0.0.0 向所有接口开放。',
'help.option.mcp.authToken':
'要求 Authorization 头携带此 Bearer Token仅与 --http 搭配使用);也可通过 GITNEXUS_MCP_AUTH_TOKEN 环境变量设置。非回环绑定(--host 0.0.0.0/::)时必填,否则拒绝启动。',
'要求 Authorization 头携带此 Bearer Token仅与 --http 搭配使用);也可通过 GITNEXUS_MCP_AUTH_TOKEN 环境变量设置,该变量同时为 gitnexus serve 的 /api/mcp 路由启用 MCP Bearer 认证。非回环绑定(--host 0.0.0.0/::)时必填,否则拒绝启动。',
'help.option.force.confirmation': '跳过确认提示',
'help.option.uninstall.force': '应用更改(默认仅为预演预览)',
'help.option.clean.all': '清理所有已索引仓库',
@ -220,7 +244,7 @@ export const zhCN = {
'清理已暂存的 LadybugDB 恢复 sidecarmissing-shadow WAL 隔离文件与 dirty-recovery 暂存文件)',
'help.option.wiki.force': '即使已是最新也强制完整重新生成',
'help.option.wiki.provider':
'LLM 提供商minimax、openai、openrouter、azure、custom、cursor、claude、codex 或 opencode默认minimax',
'LLM 提供商minimax、openai、openrouter、azure、custom、cursor、claude、codex、opencode 或 grok默认minimax',
'help.option.wiki.model': 'LLM 模型或 deployment 名称默认MiniMax-M3',
'help.option.wiki.baseUrl':
'LLM API base URL。Azure v1https://{resource}.openai.azure.com/openai/v1',
@ -273,10 +297,9 @@ export const zhCN = {
'同时下载 CUDA GPU 二进制文件(运行 onnxruntime-node 的 NuGet postinstall代理后请设置 GLOBAL_AGENT_HTTPS_PROXY',
'help.option.embeddings.install.force': '即使嵌入组件已可解析,也强制安装到运行时目录',
'help.option.group.create.force': '覆盖现有仓库组',
'help.option.group.sync.skipEmbeddings': '仅使用 exact + BM25不使用嵌入回退',
'help.option.group.sync.exactOnly': '仅精确匹配',
'help.option.group.sync.allowStale': '跳过过期索引警告',
'help.option.group.sync.verbose': '显示每条跨仓库链接详情',
'help.option.group.sync.exactOnly':
'跳过通配符服务匹配,仅按契约 ID 精确匹配建立跨仓链接(清单声明的链接仍然生效)',
'help.option.group.sync.verbose': '显示额外的同步诊断信息',
'help.option.status.json': '输出机器可读的索引和分析器来源信息',
'help.option.json': 'JSON 输出',
'help.option.group.impact.target': '要分析的符号或文件名',

View file

@ -45,6 +45,22 @@ program
.option('-f, --force', 'Apply the changes (default is a dry-run preview)')
.action(createLazyAction(() => import('./uninstall.js'), 'uninstallCommand'));
program
.command('auto-sync [action]')
.description(
'Control scheduled repository clone/pull and analysis from GITNEXUS_HOME/watch_config.yml',
)
.addHelpText('after', () => t('help.autoSync.details'))
.action(createLazyAction(() => import('./auto-sync.js'), 'autoSyncCommand'));
program
.command('watch [action]')
.description(
'Ambiguous: use `analyze --watch` for local files, or `auto-sync` for scheduled remotes',
)
.addHelpText('after', () => t('help.watch.details'))
.action(createLazyAction(() => import('./watch.js'), 'watchAmbiguousCommand'));
// Baseline of GITNEXUS_EMBEDDING_DIMS captured by the analyze preAction hook
// before it overwrites the var, so the postAction hook can restore it. The
// analyzeCommand env snapshot is taken AFTER this hook runs, so it cannot undo
@ -57,6 +73,8 @@ let dimsEnvCaptured = false;
program
.command('analyze [path]')
.description('Index a repository (full analysis)')
.option('--watch', 'Keep the index current with serialized incremental refreshes')
.option('--debounce <ms>', 'Watch quiet period before refreshing (default: 300 milliseconds)')
.option('-f, --force', 'Force full re-index even if up to date')
.option('--repair-fts', 'Repair/rebuild search FTS indexes without full re-analysis')
.option(
@ -74,7 +92,10 @@ program
'Generate repo-specific skill files from detected communities ' +
'(no-op when --index-only is also set).',
)
.option('--skip-agents-md', 'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md')
.option(
'--skip-agents-md',
'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md. Does not skip standard skills in .claude/skills or .agents/skills; use --skip-skills for those. Community skills from --skills are unaffected.',
)
.option(
'--pdg',
'Build the control-flow-graph / PDG substrate (BasicBlock nodes + CFG edges) ' +
@ -137,6 +158,11 @@ program
'--workers <n>',
'Parse worker pool size (>=1). Default: cores-1 capped at 16, auto-sized to the repo.',
)
.option(
'--spring-actuator <path>',
'Import local Spring Boot Actuator JSON snapshots (mappings, beans, conditions, ' +
'configprops, env). Explicit opt-in; disabled by default.',
)
.option('--embedding-threads <n>', 'Limit local ONNX embedding CPU threads')
.option('--embedding-batch-size <n>', 'Number of nodes per embedding batch')
.option('--embedding-sub-batch-size <n>', 'Number of chunks per embedding model call')
@ -162,6 +188,11 @@ program
)
.addHelpText('after', () => t('help.analyze.environment'))
.hook('preAction', (thisCommand: Command) => {
const analyzeOpts = thisCommand.opts();
if (analyzeOpts['debounce'] !== undefined && analyzeOpts['watch'] !== true) {
process.stderr.write('\n --debounce requires --watch\n\n');
process.exit(1);
}
// ONLY GITNEXUS_EMBEDDING_DIMS must be set here: schema.ts reads it at
// module-load time during the lazy import('./analyze.js') below (via the
// static chain analyze.ts → run-analyze.ts → schema.ts), so deferring to
@ -169,7 +200,7 @@ program
// lazily at runtime (readConfig), so analyzeCommandImpl is their sole
// setter — keeping them out of this hook means they fall under the impl's
// env snapshot/restore and don't leak across in-process invocations.
const dimsOpt = thisCommand.opts()['embeddingDims'];
const dimsOpt = analyzeOpts['embeddingDims'];
if (dimsOpt !== undefined) {
// Validate + normalize BEFORE writing the env var: schema.ts throws on a
// bad value at module-load, which — on the synchronous program.parse()
@ -202,7 +233,7 @@ program
createAnalyzerLbugLazyAction(
() => import('../core/analyzer-identity.js'),
() => import('./analyze.js'),
'analyzeCommandWithRunnerIdentity',
'analyzeOrWatchCommandWithRunnerIdentity',
import.meta.url,
),
);
@ -238,7 +269,7 @@ program
)
.option(
'--auth-token <token>',
'Require this bearer token in the Authorization header (only with --http); may also be set via the GITNEXUS_MCP_AUTH_TOKEN env var. Required for a non-loopback bind (--host 0.0.0.0/::), which otherwise refuses to start.',
"Require this bearer token in the Authorization header (only with --http); may also be set via the GITNEXUS_MCP_AUTH_TOKEN env var, which also enables MCP Bearer auth on gitnexus serve's /api/mcp route. Required for a non-loopback bind (--host 0.0.0.0/::), which otherwise refuses to start.",
)
.action(createLbugLazyAction(() => import('./mcp.js'), 'mcpCommand'));
@ -303,7 +334,7 @@ program
.option('-f, --force', 'Force full regeneration even if up to date')
.option(
'--provider <provider>',
'LLM provider: minimax, openai, openrouter, azure, custom, cursor, claude, codex, or opencode (default: minimax)',
'LLM provider: minimax, openai, openrouter, azure, custom, cursor, claude, codex, opencode, or grok (default: minimax)',
)
.option('--model <model>', 'LLM model or deployment name (default: MiniMax-M3)')
.option(

View file

@ -1090,14 +1090,30 @@ async function installSkillsTo(targetDir: string): Promise<string[]> {
const skillDir = path.join(targetDir, skillName);
try {
if (source.isDirectory) {
const dirSource = path.join(skillsRoot, skillName);
await copyDirRecursive(dirSource, skillDir);
} else {
const flatSource = path.join(skillsRoot, `${skillName}.md`);
const content = await fs.readFile(flatSource, 'utf-8');
const sourceSkillPath = source.isDirectory
? path.join(skillsRoot, skillName, 'SKILL.md')
: path.join(skillsRoot, `${skillName}.md`);
const destinationSkillPath = path.join(skillDir, 'SKILL.md');
const [sourceSkillContent, destinationSkillContent] = await Promise.all([
fs.readFile(sourceSkillPath, 'utf-8'),
fs.readFile(destinationSkillPath, 'utf-8').catch((err) => {
if (!isEnoent(err)) throw err;
return null;
}),
]);
const preserved =
destinationSkillContent !== null && destinationSkillContent !== sourceSkillContent;
if (preserved && !source.isDirectory) {
console.log(
`[gitnexus] preserved customized skill ${destinationSkillPath}; ` +
'delete the file and rerun setup to refresh it.',
);
} else if (source.isDirectory) {
await copyDirRecursive(path.join(skillsRoot, skillName), skillDir);
} else if (!preserved) {
await fs.mkdir(skillDir, { recursive: true });
await fs.writeFile(path.join(skillDir, 'SKILL.md'), content, 'utf-8');
await fs.writeFile(destinationSkillPath, sourceSkillContent, 'utf-8');
}
// A directory superseded by a shipped rename is warned about, never
@ -1113,7 +1129,7 @@ async function installSkillsTo(targetDir: string): Promise<string[]> {
);
}
}
installed.push(skillName);
if (!preserved) installed.push(skillName);
} catch {
// Source skill not found — skip
}
@ -1133,9 +1149,23 @@ async function copyDirRecursive(src: string, dest: string): Promise<void> {
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
await copyDirRecursive(srcPath, destPath);
} else {
await fs.copyFile(srcPath, destPath);
continue;
}
const [srcBuf, destBuf] = await Promise.all([
fs.readFile(srcPath),
fs.readFile(destPath).catch((err) => {
if (!isEnoent(err)) throw err;
return null;
}),
]);
if (destBuf !== null && !destBuf.equals(srcBuf)) {
console.log(
`[gitnexus] preserved customized skill ${destPath}; ` +
'delete the file and rerun setup to refresh it.',
);
continue;
}
await fs.writeFile(destPath, srcBuf);
}
}

View file

@ -18,8 +18,69 @@ import {
resolveAnalyzerRunnerIdentity,
} from '../core/analyzer-identity.js';
import { getIndexIncompleteReasons } from '../core/index-freshness.js';
import { detectIndexContentDrift, type IndexContentDrift } from '../core/index-content-drift.js';
import { t } from './i18n/index.js';
/** How many drifted paths the report names before summarizing the rest. */
const DRIFT_SAMPLE_LIMIT = 10;
/**
* Machine-readable form of the per-file comparison. `'not-checked'` is its own
* value rather than a silent omission: it says the index was already stale on
* metadata alone, so the scan was skipped, which is not the same claim as a
* scan that ran and found nothing.
*/
const describeContentDrift = (drift: IndexContentDrift | undefined) => {
if (!drift) return { status: 'not-checked' as const };
if (drift.kind === 'current') {
return { status: 'current' as const, coveredFiles: drift.coveredFileCount };
}
if (drift.kind === 'unmeasurable') {
return { status: 'unmeasurable' as const, reason: drift.reason };
}
return {
status: 'drifted' as const,
counts: {
changed: drift.changed.length,
added: drift.added.length,
deleted: drift.deleted.length,
},
changed: drift.changed.slice(0, DRIFT_SAMPLE_LIMIT),
added: drift.added.slice(0, DRIFT_SAMPLE_LIMIT),
deleted: drift.deleted.slice(0, DRIFT_SAMPLE_LIMIT),
truncated: {
changed: drift.changed.length > DRIFT_SAMPLE_LIMIT,
added: drift.added.length > DRIFT_SAMPLE_LIMIT,
deleted: drift.deleted.length > DRIFT_SAMPLE_LIMIT,
},
};
};
/** Escape control characters in repo-relative paths before printing. */
const formatDriftPath = (rel: string): string =>
/[\u0000-\u001f\u007f]/.test(rel) ? JSON.stringify(rel) : rel;
const printDriftDetail = (drift: Extract<IndexContentDrift, { kind: 'drifted' }>): void => {
console.log(
t('status.indexContentDrifted', {
changed: drift.changed.length,
added: drift.added.length,
deleted: drift.deleted.length,
}),
);
const labelled: [string, readonly string[]][] = [
[t('status.driftChanged'), drift.changed],
[t('status.driftAdded'), drift.added],
[t('status.driftDeleted'), drift.deleted],
];
for (const [label, paths] of labelled) {
for (const p of paths.slice(0, DRIFT_SAMPLE_LIMIT)) {
console.log(` ${label}: ${formatDriftPath(p)}`);
}
const remaining = paths.length - DRIFT_SAMPLE_LIMIT;
if (remaining > 0) console.log(t('status.indexContentMore', { count: remaining, label }));
}
};
export interface StatusOptions {
json?: boolean;
}
@ -85,14 +146,36 @@ export const statusCommand = async (options: StatusOptions = {}) => {
currentRunnerIdentity,
);
const incompleteReasons = getIndexIncompleteReasons(activeMeta);
// A matching HEAD is not enough: `analyze` re-indexes a dirty working tree,
// so a repo with uncommitted source changes is stale even at the same commit.
// Skip the check for non-git folders (currentCommit === '') to match analyze.
const isUpToDate =
const metadataIsCurrent =
currentCommit === activeMeta.lastCommit &&
runnerIdentityIsCurrent &&
incompleteReasons.length === 0 &&
(currentCommit === '' || !isWorkingTreeDirty(repo.repoPath));
incompleteReasons.length === 0;
// A matching HEAD is not enough: `analyze` re-indexes changed content at the
// same commit, so the files the index covers must still be compared against
// disk. Only worth the scan once the cheap metadata checks agree, and skipped
// for non-git folders (currentCommit === '') to match analyze.
const contentDrift: IndexContentDrift | undefined =
metadataIsCurrent && currentCommit !== ''
? await detectIndexContentDrift(
repo.repoPath,
activeMeta.fileHashes,
activeMeta.indexCoverage,
)
: undefined;
// The repo-wide dirty flag survives only as the fallback for metadata written
// before `fileHashes` existed. Where the per-file comparison can run it
// decides, so a file the index does not cover no longer pins a byte-current
// index to a "stale" verdict that `analyze` is powerless to clear (#3077).
const contentIsCurrent =
contentDrift === undefined ||
contentDrift.kind === 'current' ||
(contentDrift.kind === 'unmeasurable' &&
contentDrift.reason === 'no-file-hashes' &&
!isWorkingTreeDirty(repo.repoPath));
const isUpToDate = metadataIsCurrent && contentIsCurrent;
if (options.json) {
console.log(
JSON.stringify({
@ -111,6 +194,7 @@ export const statusCommand = async (options: StatusOptions = {}) => {
commit: currentCommit,
runnerIdentity: currentRunnerIdentity,
},
contentDrift: describeContentDrift(contentDrift),
status: isUpToDate ? 'up-to-date' : 'stale',
}),
);
@ -137,5 +221,16 @@ export const statusCommand = async (options: StatusOptions = {}) => {
console.log(`Index incomplete reasons: ${JSON.stringify(incompleteReasons)}`);
}
console.log(`${t('status.currentRunnerIdentity')}: ${JSON.stringify(currentRunnerIdentity)}`);
if (contentDrift?.kind === 'current') {
console.log(t('status.indexContentCurrent', { count: contentDrift.coveredFileCount }));
} else if (contentDrift?.kind === 'drifted') {
printDriftDetail(contentDrift);
} else if (contentDrift?.kind === 'unmeasurable') {
if (contentDrift.reason === 'scan-failed') {
console.log(t('status.indexContentScanFailed'));
} else if (!isUpToDate) {
console.log(t('status.indexContentUnmeasurable', { reason: contentDrift.reason }));
}
}
console.log(`${t('status.status')}: ${isUpToDate ? t('status.upToDate') : t('status.stale')}`);
};

View file

@ -0,0 +1,184 @@
export type WatchRefresh = (paths: readonly string[]) => Promise<void>;
export type WatchRefreshError = (error: unknown, paths: readonly string[]) => void;
export const WATCH_FULL_REFRESH_PATH = '*';
export interface WatchRefreshQueueOptions {
readonly maxWaitMs?: number;
readonly maxPendingPaths?: number;
readonly retryBaseDelayMs?: number;
readonly retryMaxDelayMs?: number;
readonly holdEventsUntilInitialRefresh?: boolean;
readonly isPriorityPath?: (filePath: string) => boolean;
}
/** Debounces filesystem events and guarantees that refreshes never overlap. */
export class WatchRefreshQueue {
private readonly pending = new Set<string>();
private readonly idleWaiters = new Set<() => void>();
private timer: ReturnType<typeof setTimeout> | undefined;
private active: Promise<void> | undefined;
private closed = false;
private initialPending = false;
private firstPendingAt: number | undefined;
private overflowed = false;
private consecutiveFailures = 0;
private retryNotBefore: number | undefined;
constructor(
private readonly refresh: WatchRefresh,
private readonly onError: WatchRefreshError,
private readonly debounceMs: number,
private readonly options: WatchRefreshQueueOptions = {},
) {
this.initialPending = options.holdEventsUntilInitialRefresh === true;
}
enqueue(filePath: string): void {
if (this.closed) return;
this.addPendingPath(filePath);
this.firstPendingAt ??= Date.now();
if (!this.initialPending && this.active === undefined) this.schedule();
}
private addPendingPath(filePath: string): void {
const maxPendingPaths = this.options.maxPendingPaths ?? 1_000;
const priority = this.options.isPriorityPath?.(filePath) === true;
if (this.pending.has(filePath)) {
// A duplicate does not increase memory use or imply that paths were dropped.
} else if (this.pending.size < maxPendingPaths) {
this.pending.add(filePath);
} else {
this.overflowed = true;
if (priority) {
const evictable = [...this.pending].find(
(pendingPath) => this.options.isPriorityPath?.(pendingPath) !== true,
);
if (evictable !== undefined) {
this.pending.delete(evictable);
this.pending.add(filePath);
}
}
}
}
/** Run the initial refresh while still queueing events that arrive during it. */
async runInitial(): Promise<void> {
if (this.closed) return;
if (this.active !== undefined) throw new Error('Watch refresh is already running');
try {
await this.runBatch([], true);
} finally {
this.initialPending = false;
if (!this.closed && this.hasPendingWork()) this.schedule();
else this.resolveIdleWaiters();
}
}
async waitForIdle(): Promise<void> {
if (this.isIdle()) return;
await new Promise<void>((resolve) => this.idleWaiters.add(resolve));
}
async close(): Promise<void> {
this.closed = true;
if (this.timer !== undefined) clearTimeout(this.timer);
this.timer = undefined;
this.pending.clear();
this.firstPendingAt = undefined;
this.overflowed = false;
this.consecutiveFailures = 0;
this.retryNotBefore = undefined;
// A refresh rejection is already surfaced through `onError` (or through
// runInitial). Closing from that handler can race the runBatch `finally`,
// so consume the same rejection here instead of reporting it twice.
await this.active?.catch(() => {});
this.resolveIdleWaiters();
}
private schedule(retryDelayMs?: number): void {
if (this.timer !== undefined) clearTimeout(this.timer);
const maxWaitMs = this.options.maxWaitMs ?? Math.max(this.debounceMs, 2_000);
const now = Date.now();
if (retryDelayMs !== undefined) this.retryNotBefore = now + retryDelayMs;
const elapsed = this.firstPendingAt === undefined ? 0 : now - this.firstPendingAt;
const debounced = Math.max(0, Math.min(this.debounceMs, maxWaitMs - elapsed));
// An event arriving mid-backoff merges into the pending batch but must not
// pull the retry earlier than the deadline the backoff already committed to.
const delay =
retryDelayMs ??
(this.retryNotBefore === undefined
? debounced
: Math.max(debounced, this.retryNotBefore - now));
this.timer = setTimeout(() => {
this.timer = undefined;
void this.drain();
}, delay);
}
private async drain(): Promise<void> {
if (this.closed || this.active !== undefined || !this.hasPendingWork()) return;
const paths = [
...(this.overflowed ? [WATCH_FULL_REFRESH_PATH] : []),
...[...this.pending].sort(),
];
this.pending.clear();
this.firstPendingAt = undefined;
this.overflowed = false;
this.retryNotBefore = undefined;
await this.runBatch(paths, false);
}
private async runBatch(paths: readonly string[], propagateError: boolean): Promise<void> {
let work: Promise<void>;
try {
work = this.refresh(paths);
} catch (error) {
work = Promise.reject(error);
}
this.active = work;
let retryDelayMs: number | undefined;
try {
await work;
this.consecutiveFailures = 0;
} catch (error) {
if (propagateError) throw error;
try {
await this.onError(error, paths);
} catch {
// Refresh failures are already handled here; a reporter must not
// reject the detached drain promise and become an unhandled rejection.
}
if (!this.closed) {
if (paths.includes(WATCH_FULL_REFRESH_PATH)) this.overflowed = true;
for (const filePath of paths) {
if (filePath !== WATCH_FULL_REFRESH_PATH) this.addPendingPath(filePath);
}
this.firstPendingAt = Date.now();
this.consecutiveFailures++;
const base = this.options.retryBaseDelayMs ?? Math.max(250, this.debounceMs);
const maximum = this.options.retryMaxDelayMs ?? 30_000;
retryDelayMs = Math.min(maximum, base * 2 ** (this.consecutiveFailures - 1));
}
} finally {
if (this.active === work) this.active = undefined;
if (!this.closed && !this.initialPending && this.hasPendingWork())
this.schedule(retryDelayMs);
else this.resolveIdleWaiters();
}
}
private hasPendingWork(): boolean {
return this.overflowed || this.pending.size > 0;
}
private isIdle(): boolean {
return this.active === undefined && this.timer === undefined && !this.hasPendingWork();
}
private resolveIdleWaiters(): void {
if (!this.isIdle() && !this.closed) return;
for (const resolve of this.idleWaiters) resolve();
this.idleWaiters.clear();
}
}

Some files were not shown because too many files have changed in this diff Show more