Merge branch 'main' into fix/dart-test-path-detection

This commit is contained in:
Gergő Magyar 2026-08-30 21:53:58 +01:00 • committed by GitHub
commit bc3b87dd7c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
244 changed files with 23580 additions and 1695 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,6 +21,8 @@ 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. |
@ -28,6 +30,8 @@ Run from the project root. This parses all source files, builds the knowledge gr
**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.
Use `node .gitnexus/run.cjs analyze --watch` for a long-lived local Git repository. It performs an initial analysis, queues scanner-admitted file changes, and retries intact failed batches with bounded backoff. Watch refreshes update only the graph: they skip AGENTS.md / CLAUDE.md injection and standard skill installation, so run a one-shot `analyze` when those generated files need updating. Watch rejects one-shot or context-output flags including `--force`, embedding flags, `--skills`, `--default-branch`, `--skip-agents-md`, `--skip-skills`, `--no-stats`, `--self-commit`, `--index-only`, and `--skip-git`. It never pulls remotes. Running MCP and `serve` processes periodically check for a published replacement and reopen it without a restart. MCP checks are throttled to once every five seconds, so a tool call before the next check can briefly use the previous index.
### status — Check index freshness
```bash
@ -55,15 +59,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 +90,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

@ -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
@ -73,6 +73,6 @@ jobs:
- '**/test/**/fixtures/**'
- 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

View file

@ -119,10 +119,10 @@ 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"})`.
- Explore with `query({search_query: "concept"})` for process-grouped flows.
- Use `context({name: "symbolName"})` for callers, callees, and flows.
- For security review, `explain({target: "fileOrSymbol"})` lists taint findings (source→sink flows; needs `analyze --pdg`).
- For control/data dependence, `pdg_query({mode: "controls", target: "fileOrSymbol"})` answers "under what condition does X run?" (CDG, incl. guard clauses) and `pdg_query({mode: "flows", target, variable})` traces "where does variable Y flow?" (REACHING_DEF). `--pdg` layer.

View file

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

View file

@ -70,10 +70,10 @@ 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"})`.
- Explore with `query({search_query: "concept"})` for process-grouped flows.
- Use `context({name: "symbolName"})` for callers, callees, and flows.
- For security review, `explain({target: "fileOrSymbol"})` lists taint findings (source→sink flows; needs `analyze --pdg`).
- For control/data dependence, `pdg_query({mode: "controls", target: "fileOrSymbol"})` answers "under what condition does X run?" (CDG, incl. guard clauses) and `pdg_query({mode: "flows", target, variable})` traces "where does variable Y flow?" (REACHING_DEF). `--pdg` layer.

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"

View file

@ -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>
@ -507,6 +530,7 @@ Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max
| `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_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. |
@ -523,7 +547,7 @@ Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max
| `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_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. |
@ -589,7 +613,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>
@ -767,6 +791,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,7 +59,8 @@ 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.

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` ~1331–1346) 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 method’s 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

@ -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,18 @@ 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). |
**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.
Use `node .gitnexus/run.cjs analyze --watch` for a long-lived local Git repository. It performs an initial analysis, queues scanner-admitted file changes, and retries intact failed batches with bounded backoff. Watch refreshes update only the graph: they skip AGENTS.md / CLAUDE.md injection and standard skill installation, so run a one-shot `analyze` when those generated files need updating. Watch rejects one-shot or context-output flags including `--force`, embedding flags, `--skills`, `--default-branch`, `--skip-agents-md`, `--skip-skills`, `--no-stats`, `--self-commit`, `--index-only`, and `--skip-git`. It never pulls remotes. Running MCP and `serve` processes periodically check for a published replacement and reopen it without a restart. MCP checks are throttled to once every five seconds, so a tool call before the next check can briefly use the previous index.
### status — Check index freshness
@ -44,10 +48,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 +59,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 +90,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

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

@ -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,7 +13,7 @@
import { tool } from '@langchain/core/tools';
import { z } from 'zod';
import { NODE_TABLES, REL_TYPES } from 'gitnexus-shared';
import { NODE_TABLES, REL_TYPES, scoreImpactRisk, unusedAxesForImpactWalk } from 'gitnexus-shared';
import type { EnrichedSearchResult, GrepResult } from '../../services/backend-client';
/**
@ -1275,6 +1275,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 +1305,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 +1340,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 +1352,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 +1378,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 +1519,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

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

@ -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 () => [],
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,6 +234,7 @@ 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)
@ -256,6 +257,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 +283,46 @@ 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.
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.
> **`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

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

@ -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,6 +14,7 @@
"@modelcontextprotocol/sdk": "^1.0.0",
"@scarf/scarf": "^1.4.0",
"busboy": "^1.6.0",
"chokidar": "^4.0.3",
"cli-progress": "^3.12.0",
"commander": "^15.0.0",
"cors": "^2.8.5",
@ -23,6 +24,7 @@
"graphology": "^0.26.0",
"graphology-indices": "^0.17.0",
"graphology-utils": "^2.3.0",
"graphql": "^16.14.2",
"ignore": "^7.0.5",
"js-yaml": "^5.0.0",
"jsonc-parser": "^3.3.1",
@ -825,9 +827,6 @@
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -844,9 +843,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -863,9 +859,6 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -882,9 +875,6 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -901,9 +891,6 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -920,9 +907,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -939,9 +923,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -958,9 +939,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -977,9 +955,6 @@
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -1002,9 +977,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -1027,9 +999,6 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -1052,9 +1021,6 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -1077,9 +1043,6 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -1102,9 +1065,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -1127,9 +1087,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -1152,9 +1109,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -2428,6 +2382,21 @@
"url": "https://github.com/chalk/chalk-template?sponsor=1"
}
},
"node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"license": "MIT",
"dependencies": {
"readdirp": "^4.0.1"
},
"engines": {
"node": ">= 14.16.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",
@ -3338,6 +3307,15 @@
"graphology-types": ">=0.23.0"
}
},
"node_modules/graphql": {
"version": "16.14.2",
"resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz",
"integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==",
"license": "MIT",
"engines": {
"node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
}
},
"node_modules/guid-typescript": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz",
@ -4543,9 +4521,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,
@ -4649,6 +4627,19 @@
"rc": "cli.js"
}
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"license": "MIT",
"engines": {
"node": ">= 14.18.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",

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,6 +60,7 @@
"@modelcontextprotocol/sdk": "^1.0.0",
"@scarf/scarf": "^1.4.0",
"busboy": "^1.6.0",
"chokidar": "^4.0.3",
"cli-progress": "^3.12.0",
"commander": "^15.0.0",
"cors": "^2.8.5",
@ -69,6 +70,7 @@
"graphology": "^0.26.0",
"graphology-indices": "^0.17.0",
"graphology-utils": "^2.3.0",
"graphql": "^16.14.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

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',
@ -232,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.
@ -271,6 +276,7 @@ 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

View file

@ -21,6 +21,8 @@ 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. |
@ -28,6 +30,8 @@ Run from the project root. This parses all source files, builds the knowledge gr
**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.
Use `node .gitnexus/run.cjs analyze --watch` for a long-lived local Git repository. It performs an initial analysis, queues scanner-admitted file changes, and retries intact failed batches with bounded backoff. Watch refreshes update only the graph: they skip AGENTS.md / CLAUDE.md injection and standard skill installation, so run a one-shot `analyze` when those generated files need updating. Watch rejects one-shot or context-output flags including `--force`, embedding flags, `--skills`, `--default-branch`, `--skip-agents-md`, `--skip-skills`, `--no-stats`, `--self-commit`, `--index-only`, and `--skip-git`. It never pulls remotes. Running MCP and `serve` processes periodically check for a published replacement and reopen it without a restart. MCP checks are throttled to once every five seconds, so a tool call before the next check can briefly use the previous index.
### status — Check index freshness
```bash
@ -55,15 +59,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 +90,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

@ -218,16 +218,16 @@ 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"})\`.
- Explore with \`query({search_query: "concept"})\` for process-grouped flows.
- Use \`context({name: "symbolName"})\` for callers, callees, and flows.
- For security review, \`explain({target: "fileOrSymbol"})\` lists taint findings (source→sink flows; needs \`analyze --pdg\`).${
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.`

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';
@ -370,7 +371,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 +379,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;
/**

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(
@ -740,6 +768,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('./watch.js');
await watchCommandWithRunnerIdentity(runnerIdentityAtBootstrap, inputPath, options);
return;
}
await analyzeCommandWithRunnerIdentity(runnerIdentityAtBootstrap, inputPath, options);
}
const analyzeCommandImpl = async (
inputPath?: string,
cliOptions?: AnalyzeOptions,
@ -1395,6 +1436,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`,

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

@ -2,6 +2,7 @@
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);
@ -134,6 +135,7 @@ export function registerGroupCommands(program: Command): void {
>;
missingRepos?: string[];
unreadableRepos?: string[];
suppressedMatchStages?: string[];
};
console.log(' Repo index / contracts staleness:');
@ -188,6 +190,18 @@ export function registerGroupCommands(program: Command): void {
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(() => {});
}
@ -196,16 +210,18 @@ 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);
@ -216,12 +232,15 @@ export function registerGroupCommands(program: Command): void {
try {
result = await syncGroup(config, {
groupDir,
allowStale: Boolean(opts.allowStale),
verbose: Boolean(opts.verbose),
skipEmbeddings: Boolean(opts.skipEmbeddings),
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
@ -257,10 +276,40 @@ export function registerGroupCommands(program: Command): void {
`\n Index them with \`gitnexus analyze\`, or remove them from group.yaml.`,
);
}
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`);
// 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
@ -391,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 {
@ -480,7 +546,15 @@ export function registerGroupCommands(program: Command): void {
return;
}
const { contracts, crossLinks, truncated, unreadableRepos, missingRepos } = raw as {
const {
contracts,
crossLinks,
truncated,
unreadableRepos,
missingRepos,
suppressedMatchStages,
truncationReason,
} = raw as {
contracts: Array<{
role: string;
contractId: string;
@ -495,6 +569,8 @@ export function registerGroupCommands(program: Command): void {
contractId: string;
}>;
truncated?: boolean;
suppressedMatchStages?: string[];
truncationReason?: string;
unreadableRepos?: string[];
missingRepos?: string[];
};
@ -518,7 +594,27 @@ export function registerGroupCommands(program: Command): void {
` ${l.from.repo} -> ${l.to.repo} [${l.matchType}, conf=${l.confidence}] ${l.contractId}`,
);
}
if (truncated) {
// 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.

View file

@ -72,6 +72,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 +157,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):',
@ -217,6 +228,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 +238,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 +247,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 +306,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 索引:',
@ -203,6 +212,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 +222,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 +231,7 @@ export const zhCN = {
'清理已暂存的 LadybugDB 恢复 sidecar(missing-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 v1:https://{resource}.openai.azure.com/openai/v1',
@ -273,10 +284,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

@ -57,6 +57,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(
@ -162,6 +164,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 +176,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 +209,7 @@ program
createAnalyzerLbugLazyAction(
() => import('../core/analyzer-identity.js'),
() => import('./analyze.js'),
'analyzeCommandWithRunnerIdentity',
'analyzeOrWatchCommandWithRunnerIdentity',
import.meta.url,
),
);
@ -238,7 +245,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 +310,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

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

501
gitnexus/src/cli/watch.ts Normal file
View file

@ -0,0 +1,501 @@
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],
['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],
['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

@ -25,6 +25,7 @@ import {
type LLMProvider,
} from '../core/wiki/llm-client.js';
import { detectCursorCLI } from '../core/wiki/cursor-client.js';
import { detectGrokCLI } from '../core/wiki/grok-client.js';
import { detectLocalCLI } from '../core/wiki/local-cli-client.js';
import { logger } from '../core/logger.js';
@ -65,20 +66,22 @@ function parsePositiveIntegerOption(
function isLocalProvider(
provider: LLMProvider | undefined,
): provider is 'cursor' | 'claude' | 'codex' | 'opencode' {
): provider is 'cursor' | 'claude' | 'codex' | 'opencode' | 'grok' {
return (
provider === 'cursor' ||
provider === 'claude' ||
provider === 'codex' ||
provider === 'opencode'
provider === 'opencode' ||
provider === 'grok'
);
}
function localModelConfigKey(provider: 'cursor' | 'claude' | 'codex' | 'opencode') {
function localModelConfigKey(provider: 'cursor' | 'claude' | 'codex' | 'opencode' | 'grok') {
if (provider === 'cursor') return 'cursorModel';
if (provider === 'claude') return 'claudeModel';
if (provider === 'codex') return 'codexModel';
if (provider === 'opencode') return 'opencodeModel';
if (provider === 'grok') return 'grokModel';
throw new Error(`Unsupported local provider: ${provider satisfies never}`);
}
@ -287,7 +290,9 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions)
if (!llmConfig.apiKey && !isLocalProvider(llmConfig.provider)) {
console.log(' Error: No LLM API key found.');
console.log(' Set MINIMAX_API_KEY, GITNEXUS_API_KEY, or OPENAI_API_KEY,');
console.log(' or pass --api-key <key>, or use --provider cursor|claude|codex|opencode.\n');
console.log(
' or pass --api-key <key>, or use --provider cursor|claude|codex|opencode|grok.\n',
);
process.exitCode = 1;
return;
}
@ -301,9 +306,10 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions)
const hasClaude = detectLocalCLI('claude');
const hasCodex = detectLocalCLI('codex');
const hasOpenCode = detectLocalCLI('opencode');
const hasGrok = detectGrokCLI();
const localChoices: Array<{
choice: string;
provider: 'cursor' | 'claude' | 'codex' | 'opencode';
provider: 'cursor' | 'claude' | 'codex' | 'opencode' | 'grok';
}> = [];
// Provider selection
@ -346,6 +352,14 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions)
});
console.log(` [${choice}] OpenCode CLI (local, uses your OpenCode login/config)`);
}
if (hasGrok) {
const choice = String(nextChoice++);
localChoices.push({
choice,
provider: 'grok',
});
console.log(` [${choice}] Grok CLI (local, uses your Grok Build login)`);
}
console.log('');
const maxChoice = String(nextChoice - 1);

View file

@ -3,6 +3,7 @@ import { existsSync } from 'fs';
import fs from 'fs/promises';
import nodePath from 'path';
import type { Path } from 'path-scurry';
import { readRepoControlFile } from './repo-control-file.js';
import { logger } from '../core/logger.js';
import { getCoreExcludesFilePath, getGitInfoExcludePath } from '../storage/git.js';
@ -58,13 +59,33 @@ const DEFAULT_IGNORE_LIST = new Set([
'obj',
'target', // Java/Rust
'.next',
// `.next` is Next.js's build CACHE; `_next` is the EMITTED output, and the two
// are different directories. A Capacitor/Cordova shell copies the emitted
// bundle to `<platform>/app/src/main/assets/public/_next/static/…`, where none
// of the path segments hit this list — so a mobile-wrapped Next.js app had its
// shipped bundle indexed as source, and every Route node it produced pointed at
// a webpack chunk rather than code anyone wrote (#3007).
//
// The name is deliberately unanchored. No `<web-root>/_next` form matches a
// root-level `_next/static/…`, which is the shape the reported repo has, so
// anchoring it would miss the case it was added for. The accepted cost is a
// hand-written directory literally named `_next`; recover one with a bare
// `!_next/` line in `.gitnexusignore`.
'_next',
'.nuxt',
'.output',
'.vercel',
'.netlify',
'.serverless',
'_build',
'public/build',
// `'public/build'` used to sit here. This set is tested one path SEGMENT at a
// time, and `isHardcodedIgnoredDirectory(name)` takes a bare directory name,
// so a slash-containing member could never match either — it was inert. Its
// paths were never unignored though: bare `'build'` above already prunes
// `public/build/**`, so removing the entry changes no behavior (#3007).
// `test/unit/ignore-build-output.test.ts` keeps the next slash-bearing entry
// in this set — or in IGNORED_FILES, ROOT_ARTIFACT_DIRECTORIES or
// IGNORED_EXTENSIONS — from dying the same way.
'.parcel-cache',
'.turbo',
'.svelte-kit',
@ -95,7 +116,6 @@ const DEFAULT_IGNORE_LIST = new Set([
// remains covered by .gitignore/.gitnexusignore and the unambiguous names.
'monaco-workers', // Monaco editor web-worker bundles generated for browser runtime
'.terraform',
'.serverless',
// Documentation (optional - might want to keep)
// 'docs',
@ -382,6 +402,8 @@ export interface IgnoreOptions {
noGitignore?: boolean;
/** Skip core.excludesFile and $GIT_COMMON_DIR/info/exclude. Defaults to GITNEXUS_NO_GLOBAL_IGNORE env var. */
noGlobalIgnore?: boolean;
/** Fail repository-control reloads closed so long-lived watchers keep their prior predicate. */
strictRepoControlFiles?: boolean;
}
export const loadIgnoreRules = async (
@ -423,20 +445,56 @@ export const loadIgnoreRules = async (
for (const filename of filenames) {
try {
const content = await fs.readFile(nodePath.join(repoPath, filename), 'utf-8');
const content = options?.strictRepoControlFiles
? await readRepoControlFile(repoPath, filename)
: await fs.readFile(nodePath.join(repoPath, filename), 'utf-8');
if (content === null) continue;
ig.add(content);
hasRules = true;
} catch (err: unknown) {
const code = (err as NodeJS.ErrnoException).code;
if (code !== 'ENOENT') {
logger.warn(` Warning: could not read ${filename}: ${(err as Error).message}`);
}
if (!options?.strictRepoControlFiles && code === 'ENOENT') continue;
if (options?.strictRepoControlFiles) throw err;
logger.warn(` Warning: could not read ${filename}: ${(err as Error).message}`);
}
}
return hasRules ? ig : null;
};
/**
* Build a synchronous predicate for long-lived filesystem watchers.
*
* Unlike {@link createIgnoreFilter}, callers pass ordinary absolute or
* repository-relative paths instead of path-scurry `Path` objects. The rule
* precedence deliberately mirrors the scanner: explicit negations win over
* hardcoded defaults unless a more-specific rule re-ignores the path.
*/
export const createWatchIgnorePredicate = async (
repoPath: string,
options?: IgnoreOptions,
): Promise<(candidatePath: string, isDirectory?: boolean) => boolean> => {
const ig = await loadIgnoreRules(repoPath, { ...options, strictRepoControlFiles: true });
const repoRoot = nodePath.resolve(repoPath);
return (candidatePath: string, isDirectory = false): boolean => {
const absolute = nodePath.isAbsolute(candidatePath)
? nodePath.resolve(candidatePath)
: nodePath.resolve(repoRoot, candidatePath);
const rel = nodePath.relative(repoRoot, absolute).replace(/\\/g, '/');
if (!rel) return false;
if (rel === '..' || rel.startsWith('../') || nodePath.isAbsolute(rel)) return true;
if (ig && hasExplicitUnignore(ig, rel) && !ig.ignores(isDirectory ? `${rel}/` : rel)) {
return false;
}
if (ig && ig.ignores(isDirectory ? `${rel}/` : rel)) return true;
if (isDirectory && isHardcodedIgnoredDirectoryAtPath(repoRoot, absolute)) return true;
return shouldIgnorePath(rel);
};
};
/**
* Walk ancestor segments of `rel` and check whether `.gitnexusignore`
* (or `.gitignore`) contains an explicit `!pattern` negation that

View file

@ -0,0 +1,117 @@
import fs from 'node:fs';
import * as path from 'node:path';
export const MAX_REPO_CONTROL_FILE_BYTES = 1024 * 1024;
/** Read a bounded, regular control file owned by the repository root. */
export async function readRepoControlFile(
repoRoot: string,
filename: string,
): Promise<string | null> {
const requestedRoot = path.resolve(repoRoot);
const requested = path.resolve(requestedRoot, filename);
const relative = path.relative(requestedRoot, requested);
if (relative.startsWith('..') || path.isAbsolute(relative)) {
throw new Error(`${filename} resolves outside the repository root`);
}
try {
const canonicalRoot = fs.realpathSync(requestedRoot);
const beforeOpen = fs.lstatSync(requested);
if (beforeOpen.isSymbolicLink()) throw new Error(`${filename} must not be a symbolic link`);
if (!beforeOpen.isFile()) throw new Error(`${filename} must be a regular file`);
if (beforeOpen.nlink !== 1) throw new Error(`${filename} must not be a hard link`);
if (beforeOpen.size > MAX_REPO_CONTROL_FILE_BYTES) {
throw new Error(`${filename} exceeds ${MAX_REPO_CONTROL_FILE_BYTES} bytes`);
}
return await new Promise<string>((resolve, reject) => {
const stream = fs.createReadStream(requested, {
flags: 'r',
start: 0,
end: MAX_REPO_CONTROL_FILE_BYTES,
autoClose: true,
});
const chunks: Buffer[] = [];
let totalBytes = 0;
let validated = false;
let settled = false;
const finish = (value: string): void => {
if (settled) return;
settled = true;
resolve(value);
};
const fail = (error: unknown): void => {
if (settled) return;
settled = true;
reject(error);
};
stream.pause();
stream.once('open', (fd) => {
try {
const opened = fs.fstatSync(fd);
if (!opened.isFile()) throw new Error(`${filename} must be a regular file`);
if (opened.nlink !== 1) throw new Error(`${filename} must not be a hard link`);
if (opened.size > MAX_REPO_CONTROL_FILE_BYTES) {
throw new Error(`${filename} exceeds ${MAX_REPO_CONTROL_FILE_BYTES} bytes`);
}
const entry = fs.lstatSync(requested);
if (entry.isSymbolicLink()) throw new Error(`${filename} must not be a symbolic link`);
if (
!entry.isFile() ||
entry.nlink !== 1 ||
entry.dev !== opened.dev ||
entry.ino !== opened.ino
) {
throw new Error(`${filename} moved or was replaced while being opened`);
}
const canonicalFile = fs.realpathSync(requested);
const canonicalRelative = path.relative(canonicalRoot, canonicalFile);
if (canonicalRelative.startsWith('..') || path.isAbsolute(canonicalRelative)) {
throw new Error(`${filename} resolves outside the repository root`);
}
const canonical = fs.statSync(canonicalFile);
if (
canonical.nlink !== 1 ||
canonical.dev !== opened.dev ||
canonical.ino !== opened.ino
) {
throw new Error(`${filename} moved or was replaced while being opened`);
}
validated = true;
stream.resume();
} catch (error) {
fail(error);
stream.destroy();
}
});
stream.on('data', (chunk: Buffer | string) => {
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
totalBytes += bytes.length;
if (totalBytes > MAX_REPO_CONTROL_FILE_BYTES) {
fail(new Error(`${filename} exceeds ${MAX_REPO_CONTROL_FILE_BYTES} bytes`));
stream.destroy();
return;
}
chunks.push(bytes);
});
stream.once('end', () => {
if (!validated) {
fail(new Error(`${filename} could not be validated`));
return;
}
finish(Buffer.concat(chunks, totalBytes).toString('utf8'));
});
stream.once('error', fail);
stream.once('close', () => {
if (!settled) fail(new Error(`${filename} closed before it could be read`));
});
});
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
throw error;
}
}

View file

@ -16,10 +16,12 @@ flowchart TD
D --> E1[TopicExtractor]
D --> E2[HttpRouteExtractor]
D --> E3[GrpcExtractor]
D --> E4[GraphqlExtractor]
E1 --> F[ExtractedContract array<br/>per repo]
E2 --> F
E3 --> F
E4 --> F
B --> M[ManifestExtractor]
M --> G[Manifest contracts<br/>+ cross-links]
@ -59,14 +61,27 @@ flowchart TD
**Strategy A** (graph-assisted) uses Cypher over edges already produced
by the main ingestion pipeline:
- HTTP: `HANDLES_ROUTE` / `FETCHES` edges from `(File)-[]->(Route)`
- topic: none (pipeline doesn't yet produce topic nodes — Strategy B only)
- gRPC: none (Strategy B + proto map only)
**Strategy B** (source-scan) is 100% tree-sitter based after this PR.
**Strategy B** (source-scan) uses tree-sitter for language source and the
official GraphQL parser for `.graphql` / `.gql` operation documents.
Each `*-patterns/<lang>.ts` plugin owns its grammar + S-expression
queries; the top-level orchestrator imports neither.
GraphQL detection is opt-in with `detect.graphql: true`. The initial slice
recognizes imported NestJS `Query`, `Mutation`, and `Subscription` decorators on
top-level imported `Resolver` classes, plus named operation documents. Generated
object documents, static `gql` templates, and `TypedDocumentString` values are
verified against the operation and its resolved root fragments. Providers and
consumers must resolve to one exact, real graph symbol; anonymous operations,
dynamic decorator names, ambiguous generated symbols, malformed documents,
symlink escapes, and bounded-parser overflows are skipped rather than linked
approximately. `matching.exclude_links_paths` also suppresses configured GraphQL
root fields from exact cross-linking while retaining their registry entries.
## Plugin architecture
```mermaid
@ -117,6 +132,7 @@ They use the `MATCH (n) WHERE labels(n) IN [...]` allowlist form, NOT the
`MATCH (n:A|B)` disjunction — LadybugDB's parser rejects a disjunction that
names a reserved keyword (e.g. `Macro`, `Union`), which is what broke the
`custom` branch in #2325:
- `topic` → `labels(n) IN ['Function','Method','Class','Interface']`
- `grpc`/`thrift` method → `labels(n) IN ['Function','Method']`, service → `labels(n) IN ['Class','Interface']`
- `lib` → `labels(n) IN ['Module']`
@ -144,7 +160,7 @@ without coordinating through any shared state.
## Cross-repo trace (`cross-trace.ts`)
A second consumer of the bridge. Where cross-impact fans a blast radius
*outward* from one symbol, cross-trace stitches a directed **path** between
_outward_ from one symbol, cross-trace stitches a directed **path** between
two symbols that live in different repos:
```mermaid
@ -158,7 +174,7 @@ flowchart TD
```
It reuses the same `symbolUid` join as cross-impact, but issues its own
*pair* query (`listCrossingsBetween`) because a path needs BOTH endpoints of
_pair_ query (`listCrossingsBetween`) because a path needs BOTH endpoints of
a crossing — the uid-filtered neighbor join (`resolveBridgeNeighbors`, shared
with impact) returns only the far side. The crossing is clamped to one
boundary (`MAX_SUPPORTED_CROSS_DEPTH`). With `pdg: true` the boundary-adjacent

View file

@ -1,107 +0,0 @@
# Review findings → commits (PR #3012)
Every finding raised in review of this PR, and the commit that closes it. The
Definition of Done claims each finding has exactly one commit and that reverting
that commit reintroduces that finding and no other; this is what makes the claim
checkable without the reviewer's report in hand.
**Not under `docs/`** — that path is gitignored, so a map written there would
never reach the PR and nobody but its author could perform the audit. It lives
beside the code it describes, as `PIPELINE.md` does.
## Revert contract
Revertability is **dependency-aware**. Where one commit extracts a helper that
later commits consume, reverting the helper alone does not build. The contract
is: reverting a commit reintroduces its own finding and no other _finding_, with
its prerequisite commits retained.
One coupled set exists:
| Set | Commits | Why coupled |
| -------------------------- | --------------------------------------------------- | -------------------------------------------------------------------------------------- |
| Shared completeness helper | `4c203ac7b` ← `79f6f5bcb`, `0fe6fc9d4`, `dbc3953b0` | The three consumers call `crossRepoCompleteness`; reverting it alone breaks the build. |
## Primary findings
| # | Finding | Commit |
| --- | -------------------------------------------------------------------------------- | ----------- |
| 1 | Malformed `meta.json` crashes cross-repo impact and leaks the bridge handle | `27b0069f2` |
| 2 | Unreadable repos still contribute contracts through deferred manifest resolution | `7037e8441` |
| 3 | Strict read accepts a registry row that cannot identify a repo | `5245b22d7` |
| 4 | Unstamped bridge metadata is trusted without any check | `94f2a8757` |
| 5 | A subgroup-scoped query is marked incomplete by repos it excluded | `79f6f5bcb` |
| 6 | The preserved registry and the bridge disagree about the same sync | `4676abf03` |
| 7 | Three surfaces compute completeness three different ways | `4c203ac7b` |
| 8 | `group_contracts` has no channel for its own completeness | `0fe6fc9d4` |
| 9 | `group status` cannot tell a missing entry from an unreadable registry | `a12b846c9` |
| 10 | The sync summary describes a write that did not happen that way | `5a668455c` |
| 11 | The total-failure log promises preservation where there is nothing to preserve | `c4b356b29` |
| 12 | The bridge-failure warning promises a truncation the code never reports | `1df79bb9a` |
| 13 | Two concurrent syncs of one group lose each other's writes | `4f07359bf` |
| 14 | The bridge swap needs the lock its caller already holds | `3b6215862` |
| 15 | The byte guard misses most tracked text files, and all extensionless ones | `07bf8be75` |
| 16 | The byte guard reads the vendored grammar tree it does not need to judge | `3ef831a0a` |
| 17 | The strict-read test cannot see which registry read ran | `eccc3c682` |
| 18 | The CLI branches this PR introduced have no assertions | `535d2ad29` |
| 19 | The MCP payloads have no assertions | `2c253b4a8` |
| 20 | Corrupt-registry errors quote the file's bytes, credentials included | `24ba2a537` |
| 21 | The mtime pairing's limits are recorded nowhere a reader will look | `ca0aca106` |
| 22 | The bridge-input docstring narrows what `unreadableRepos` means | `8c930f470` |
| 23 | The strict-read docstring's call-site count is wrong | `a95838954` |
| 24 | Contract staging crashes on the engine's argument limit | `57eac7558` |
| 25 | The sync tool's description names two of three reachable outcomes | `8bfd1a6ab` |
| 26 | The impact tool and status resource do not explain incompleteness | `dbc3953b0` |
| 27 | A lock timeout blames an `analyze` it cannot establish | `2d2a0119e` |
| 28 | A losing sync downgrades the one that beat it to the lock | `e407f05cf` |
## Findings raised in review and deliberately not implemented as suggested
| Finding | Suggested fix | What shipped, and why |
| ---------------------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Unstamped metadata is trusted | Treat every absent stamp as incomplete | Rejected. It would mark every pre-existing bridge a lower bound until re-synced — a repo-wide regression traded for a narrow window. The write-order pairing in `94f2a8757` is the narrower fix. |
| Stale bridge signal after a failed write | Re-stamp the metadata so the warning's promise becomes true | Rejected. Re-stamping recreates the metadata/database mis-pairing that stamping exists to prevent. `1df79bb9a` corrects the warning instead. |
| Strict row gate | Require all three fields non-blank | Narrowed to `name` and `storagePath`. This gate rejects the whole registry, which is machine-wide, so a field tightened past what identification needs lets one blank value break every group sync on the machine. |
## Found during execution, not in the review
| What | Commit |
| --------------------------------------------------------------------------------------------- | ----------- |
| A half-written bridge stamp read as a verified match (found by the repo's own contract check) | `066f2d802` |
| `readBridgeMeta`'s widened return type blocked the merge on contract drift | `a9d281dd4` |
| `group contracts --json` discarded every field it did not re-serialize | `b7753575d` |
| `sync.ts` renders as a binary diff because the base blob carries a NUL | `1667c24b4` |
## Corrections to the plan, found while executing it
Recorded because each was a claim in the plan that the code contradicted.
| Claim | Reality |
| ------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| The strict gate should require the fields "the resolution path consumes" | `defaultResolveHandle` **does** consume `path`. The distinction is what _identifies_ the repo. |
| Pass the trace's two endpoint repos as the scope predicate | A destination trace declares no `to`. Narrowing to `from` would report an unreadable provider as "no outgoing link". |
| Filter the incomplete set by the subgroup prefix | The query's own repo must stay in scope, or an unreadable origin becomes a confident "nothing depends on this". |
| `group status`'s third failure mode is a row that resolves but cannot be opened | Unreachable — `loadMeta` returns `null` on every error and `checkStaleness` catches everything. The reachable case is `resolveRepo` throwing. |
| The mtime rule can only demote pairs already broken | False. `cp -r` and `rsync` without `-t` demote an intact pair. Recorded at the code in `ca0aca106`. |
| `.scm` files are "edited constantly" here | Every tracked `.scm` is vendored. This repo writes tree-sitter queries inline in TypeScript. |
## Residual risks, recorded rather than closed
- **Credentials in the registry.** HTTPS remote URLs are persisted with their
userinfo intact. `24ba2a537` stops one channel echoing them; it does not stop
them being written. Pre-existing, tracked separately.
- **`readRegistryFile`'s read error.** The ENOENT-guarded outer catch still
rethrows the raw `fs.readFile` error into `unresolvableReason`. Node embeds
the path, not file contents, so no registry bytes leak — but it is the one
remaining foreign error object on that path.
- **Abstract-socket lock scope.** Linux abstract sockets are
network-namespace-scoped, so two containers sharing a bind-mounted group
directory do not contend unless the file backend is forced. Recorded at
`group-lock.ts`.
- **Scope filter at depth > 1.** The declared-scope intersection is sound only
while `MAX_SUPPORTED_CROSS_DEPTH` is 1. At depth 2 an out-of-scope repo can
sit between two in-scope ones. Recorded at the intersection site.
- **R14 is unmet on this PR.** `.gitattributes` makes TypeScript diffs render as
text, and it works locally — but GitHub resolves the attribute from the base
side, which does not carry it. `sync.ts` renders as binary in this PR's web
view and will render as text for every PR after this one merges.

View file

@ -3,9 +3,16 @@ import path from 'node:path';
import { createHash } from 'node:crypto';
import lbug from '@ladybugdb/core';
import type { LbugValue } from '@ladybugdb/core';
import type { BridgeHandle, BridgeMeta, StoredContract, CrossLink, RepoSnapshot } from './types.js';
import type {
BridgeHandle,
BridgeMeta,
StoredContract,
CrossLink,
RepoSnapshot,
MatchType,
} from './types.js';
import { BRIDGE_SCHEMA_QUERIES, BRIDGE_SCHEMA_VERSION } from './bridge-schema.js';
import { recordedRepoList } from './completeness.js';
import { recordedMatchStages, recordedRepoList } from './completeness.js';
import {
closeLbugConnection,
openLbugConnection,
@ -649,7 +656,15 @@ export async function closeBridgeDb(handle: BridgeHandle): Promise<void> {
/* ------------------------------------------------------------------ */
export async function writeBridgeMeta(groupDir: string, meta: BridgeMeta): Promise<void> {
await writeFileAtomic(path.join(groupDir, 'meta.json'), JSON.stringify(meta, null, 2));
// Strip the reader-only fields HERE rather than at each writer. `readBridgeMeta`
// sets both on what it returns, so any caller that reads-modifies-writes would
// round-trip them to disk — and `pairedWithDatabase` is the poisonous one:
// persisted, it tells every future reader the pair was verified when nothing
// verified it. That rule used to live in the body of the only such caller,
// which held exactly as long as there was one. There are now three writers and
// two of them read first. Enforced at the boundary, no writer can get it wrong.
const { repoListsUnreadable: _reader1, pairedWithDatabase: _reader2, ...persisted } = meta;
await writeFileAtomic(path.join(groupDir, 'meta.json'), JSON.stringify(persisted, null, 2));
}
/**
@ -826,6 +841,10 @@ export async function readBridgeMeta(groupDir: string): Promise<BridgeMeta> {
// was there and could not be read.
if (unreadableRepos) meta.unreadableRepos = unreadableRepos;
else delete meta.unreadableRepos;
// Same absent-vs-empty rule, through the one shared reader.
const suppressed = recordedMatchStages(raw.suppressedMatchStages);
if (suppressed) meta.suppressedMatchStages = suppressed;
else delete meta.suppressedMatchStages;
if (repoListsUnreadable) meta.repoListsUnreadable = true;
return meta;
}
@ -902,6 +921,12 @@ async function fileExists(filePath: string): Promise<boolean> {
*/
export async function refreshPreservedBridgeMeta(
groupDir: string,
// Deliberately NOT `suppressedMatchStages`. This path preserves an EARLIER
// sync's database, so stamping it with this run's request would claim the
// untouched bridge was built with a flag it never saw. The registry's own
// preserve write (`{ ...prior, missingRepos, unreadableRepos }`) omits it for
// exactly this reason, and the two artifacts have to agree about which run
// they describe.
diagnostics: { missingRepos: string[]; unreadableRepos: string[] },
): Promise<PreservedBridgeMetaOutcome> {
const dbPath = path.join(groupDir, 'bridge.lbug');
@ -921,10 +946,8 @@ export async function refreshPreservedBridgeMeta(
const refreshed: BridgeMeta = { ...existing, ...diagnostics };
// NEVER PERSISTED (see `BridgeMeta`): both are things a READER computes ABOUT
// a file, and this is the first code in the repo that reads metadata and
// writes it back. `pairedWithDatabase` is the poisonous one — persisted, it
// would tell every future reader that the pair had been verified.
delete refreshed.repoListsUnreadable;
delete refreshed.pairedWithDatabase;
// writes it back. The strip itself now lives in `writeBridgeMeta`, so every
// writer inherits it rather than each remembering.
if (paired) {
const stat = await fsp.stat(dbPath).catch(() => null);
@ -944,6 +967,39 @@ export async function refreshPreservedBridgeMeta(
return 'provenance-unknown';
}
/**
* Withdraw the bridge's claim to be complete, without touching the database.
*
* The one path this exists for: `contracts.json` committed, then the bridge
* replacement failed. The old database is still physically usable and still
* answers queries, but it now describes an EARLIER sync than the canonical
* registry beside it — so `group_contracts` can report a narrowed or advanced
* contract set while `group_impact` traverses the old graph and calls its
* answer complete. Two public surfaces, contradictory epistemic claims, from
* one sync.
*
* Setting `provenanceUnknown` is the smallest thing that makes that safe:
* `bridgeMetaMatchesFile` gives it highest precedence and refuses to vouch for
* the pair, so every cross-repo answer downgrades to a floor until a sync
* succeeds. Deliberately NOT a re-stamp — the metadata still describes the
* database it was written for, and claiming otherwise is the mis-pairing the
* preserve path is careful to avoid. Deliberately not a delete either: the
* previous graph is better than nothing as long as nobody calls it complete.
*
* Best-effort by construction. It runs inside a failure handler, so a throw
* here would replace a reported bridge failure with an unrelated one.
*/
export async function markBridgeProvenanceUnknown(groupDir: string): Promise<boolean> {
try {
const existing = await readBridgeMeta(groupDir);
if (existing.version === 0) return false;
await writeBridgeMeta(groupDir, { ...existing, provenanceUnknown: true });
return true;
} catch {
return false;
}
}
/* ------------------------------------------------------------------ */
/* writeBridge — atomic write-to-temp-then-rename */
/* ------------------------------------------------------------------ */
@ -969,6 +1025,13 @@ export interface WriteBridgeInput {
* contract those repos own.
*/
unreadableRepos?: string[];
/**
* Matching stages the sync was asked to skip. Recorded here for the same
* reason `unreadableRepos` is: a later cross-repo query reads this bridge
* with no access to the run that built it, and a graph narrowed by request
* looks exactly like a complete one.
*/
suppressedMatchStages?: MatchType[];
}
/**
@ -1322,6 +1385,9 @@ export async function writeBridgeUnlocked(
// different claim from a bridge that never recorded the field. Omitted
// only when the caller passed nothing to record.
...(input.unreadableRepos ? { unreadableRepos: input.unreadableRepos } : {}),
...(input.suppressedMatchStages
? { suppressedMatchStages: input.suppressedMatchStages }
: {}),
});
return report;

View file

@ -13,7 +13,7 @@
* Nothing here imports anything but types. Keep it that way: the moment this
* file gains a runtime import, every consumer pays for it again.
*/
import type { GroupImpactTruncationReason } from './types.js';
import type { GroupImpactTruncationReason, MatchType } from './types.js';
/**
* A union rather than `Pick<GroupImpactResult, ...>` so the two states are
@ -69,6 +69,12 @@ export interface CrossRepoCompletenessInput {
*/
unreadableRepos?: readonly string[];
missingRepos?: readonly string[];
/**
* Matching stages the sync was asked to skip. Absent or empty means it
* suppressed none; a populated list makes the answer a floor for a reason
* that is neither a runtime limit nor an unreadable repo.
*/
suppressedMatchStages?: readonly string[];
/** Computed by the caller; see `bridgeProvenanceUnknown` for the bridge one. */
provenanceUnknown: boolean;
/**
@ -93,6 +99,23 @@ export type CrossRepoCompleteness = TruncationFields & {
incompleteRepos: string[];
};
/**
* Read a persisted `suppressedMatchStages` list.
*
* Sibling of `recordedRepoList` and here for the same stated reason: it had
* lived in two files verbatim, so tightening one would silently leave the other.
* All-or-nothing like its sibling — a stale member (this repo has already
* retired `'bm25'` and `'embedding'`) makes the whole list unreadable rather
* than filtering down to `[]`, which on this field would mean "measured,
* nothing suppressed": a clean answer manufactured from a value we could not
* read.
*/
export function recordedMatchStages(value: unknown): MatchType[] | undefined {
if (!Array.isArray(value)) return undefined;
const known: MatchType[] = ['exact', 'manifest', 'wildcard'];
return value.every((v): v is MatchType => known.includes(v as MatchType)) ? value : undefined;
}
/**
* The ONE computation of "is this cross-repo answer complete?" (KTD10).
*
@ -111,8 +134,17 @@ export function crossRepoCompleteness(input: CrossRepoCompletenessInput): CrossR
const incompleteRepos = [
...new Set([...(input.unreadableRepos ?? []), ...(input.missingRepos ?? [])]),
].filter((repoPath) => input.inScope(repoPath));
// An unreadable or unaccounted repo outranks a suppressed stage: it is the
// more serious structural gap and its remedy (repair the repo, re-sync) has
// to be the one reported. A suppressed stage only decides the reason when
// the repo side is otherwise clean.
const suppressed = (input.suppressedMatchStages ?? []).length > 0;
const repoSideIncomplete = input.provenanceUnknown || incompleteRepos.length > 0;
return {
...truncationFields(input.provenanceUnknown || incompleteRepos.length > 0, 'incomplete-sync'),
...truncationFields(
repoSideIncomplete || suppressed,
repoSideIncomplete ? 'incomplete-sync' : 'suppressed-stage',
),
incompleteRepos,
};
}

View file

@ -1,10 +1,15 @@
import { createRequire } from 'node:module';
import type { GroupConfig, GroupManifestLink, ContractType, ContractRole } from './types.js';
import type {
ContractRole,
GroupConfig,
GroupManifestLink,
ManifestContractType,
} from './types.js';
const _require = createRequire(import.meta.url);
const yaml = _require('js-yaml') as typeof import('js-yaml');
const VALID_CONTRACT_TYPES: ContractType[] = [
const VALID_CONTRACT_TYPES: ManifestContractType[] = [
'http',
'grpc',
'thrift',
@ -26,19 +31,15 @@ const VALID_ROLES: ContractRole[] = ['provider', 'consumer'];
// repos that need cross-repo header tracking.
const DEFAULT_DETECT = {
http: true,
graphql: false,
grpc: true,
thrift: true,
topics: true,
shared_libs: true,
embedding_fallback: true,
includes: false,
workspace_deps: false,
};
const DEFAULT_MATCHING = {
bm25_threshold: 0.7,
embedding_threshold: 0.65,
max_candidates_per_step: 3,
exclude_links_paths: [] as string[],
exclude_links_param_only_paths: false,
};
@ -59,7 +60,16 @@ export function parseGroupConfig(yamlContent: string): GroupConfig {
throw new Error('repos is required in group.yaml (must be a mapping)');
}
const repos = raw.repos as Record<string, string>;
const reposRaw = raw.repos as Record<string, unknown>;
const repos: Record<string, string> = {};
for (const [memberPath, registryName] of Object.entries(reposRaw)) {
if (typeof registryName !== 'string' || registryName.trim() === '') {
throw new Error(
`repos["${memberPath}"] must be a non-empty registry name string, not ${typeof registryName}`,
);
}
repos[memberPath] = registryName.trim();
}
const repoPaths = new Set(Object.keys(repos));
const rawLinks = (raw.links as unknown[]) || [];
@ -71,7 +81,7 @@ export function parseGroupConfig(yamlContent: string): GroupConfig {
if (!link.to || !repoPaths.has(link.to as string)) {
throw new Error(`links[${i}].to "${link.to}" does not match any repo path in group`);
}
if (!VALID_CONTRACT_TYPES.includes(link.type as ContractType)) {
if (!VALID_CONTRACT_TYPES.includes(link.type as ManifestContractType)) {
throw new Error(
`links[${i}].type "${link.type}" is invalid. Expected: ${VALID_CONTRACT_TYPES.join(', ')}`,
);
@ -89,13 +99,25 @@ export function parseGroupConfig(yamlContent: string): GroupConfig {
return {
from: link.from as string,
to: link.to as string,
type: link.type as ContractType,
type: link.type as ManifestContractType,
contract: String(link.contract),
role: link.role as ContractRole,
};
});
const detect = { ...DEFAULT_DETECT, ...((raw.detect as object) || {}) };
const rawDetect = raw.detect;
if (
rawDetect !== undefined &&
(!rawDetect || typeof rawDetect !== 'object' || Array.isArray(rawDetect))
) {
throw new Error('detect must be a mapping of boolean flags');
}
for (const [key, value] of Object.entries((rawDetect as Record<string, unknown>) || {})) {
if (key in DEFAULT_DETECT && typeof value !== 'boolean') {
throw new Error(`detect.${key} must be true or false`);
}
}
const detect = { ...DEFAULT_DETECT, ...((rawDetect as object) || {}) };
const matching = { ...DEFAULT_MATCHING, ...((raw.matching as object) || {}) };
const packages = (raw.packages as Record<string, Record<string, string>>) || {};

View file

@ -5,6 +5,7 @@
import fsp from 'node:fs/promises';
import path from 'node:path';
import type { ImpactRisk } from 'gitnexus-shared';
import type {
BridgeHandle,
BridgeMeta,
@ -243,6 +244,17 @@ async function resolveGroupRepo(
): Promise<GroupRepoHandle | { error: string }> {
const registryName = config.repos[repoPath];
if (!registryName) {
const matchingMemberPaths = Object.entries(config.repos)
.filter(([, alias]) => alias.toLowerCase() === repoPath.toLowerCase())
.map(([memberPath]) => memberPath);
if (matchingMemberPaths.length > 0) {
return {
error:
`Unknown repo path "${repoPath}" in this group. ` +
`That value is a registry alias for member path(s): ${matchingMemberPaths.join(', ')}. ` +
`Pass the group.yaml key to --repo, not the alias.`,
};
}
return { error: `Unknown repo path "${repoPath}" in this group.` };
}
try {
@ -381,7 +393,17 @@ function extractProcessNames(impact: unknown): string[] {
// permanently that a PDG `risk:'UNKNOWN'` never coalesces to a confident `LOW`.
// No behavior change — `'UNKNOWN'` was already handled correctly at the
// `(localRisk === 'LOW' || localRisk === 'UNKNOWN')` branch below.
export function mergeRisk(localRisk: string, cross: CrossRepoImpact[]): string {
function asImpactRisk(value: unknown, fallback: ImpactRisk = 'LOW'): ImpactRisk {
return value === 'LOW' ||
value === 'MEDIUM' ||
value === 'HIGH' ||
value === 'CRITICAL' ||
value === 'UNKNOWN'
? value
: fallback;
}
export function mergeRisk(localRisk: ImpactRisk, cross: CrossRepoImpact[]): ImpactRisk {
const traversed = cross.filter((c) => c.fanout_status !== 'not_attempted');
const highConf = traversed.some((c) => c.contract.confidence >= 0.85);
if (localRisk === 'CRITICAL') return 'CRITICAL';
@ -391,6 +413,22 @@ export function mergeRisk(localRisk: string, cross: CrossRepoImpact[]): string {
return localRisk;
}
function liftLocalRiskMeta(
local: unknown,
cross: CrossRepoImpact[],
): Pick<GroupImpactResult, 'riskSharedAxes' | 'riskScale'> {
const { riskSharedAxes, riskScale } = local as {
riskSharedAxes?: unknown;
riskScale?: GroupImpactResult['riskScale'];
};
return {
...(riskSharedAxes !== undefined
? { riskSharedAxes: mergeRisk(asImpactRisk(riskSharedAxes), cross) }
: {}),
...(riskScale !== undefined ? { riskScale } : {}),
};
}
/**
* Is this bridge's metadata unable to say where its contents came from?
*
@ -601,6 +639,7 @@ export async function runGroupImpact(
cross_repo_hits: 0,
},
risk: 'UNKNOWN',
...liftLocalRiskMeta(local, []),
timeoutMs,
crossDepthWarning,
};
@ -656,7 +695,8 @@ export async function runGroupImpact(
modules_affected: s.modules_affected ?? 0,
cross_repo_hits: 0,
},
risk: String((local as { risk?: string }).risk ?? 'LOW'),
risk: asImpactRisk((local as { risk?: unknown }).risk),
...liftLocalRiskMeta(local, []),
timeoutMs,
crossDepthWarning,
};
@ -826,7 +866,7 @@ export async function runGroupImpact(
}
const localSum = (local as { summary?: Record<string, number> })?.summary || {};
const localRisk = String((local as { risk?: string }).risk ?? 'LOW');
const localRisk = asImpactRisk((local as { risk?: unknown }).risk);
const localPartial = Boolean((local as { partial?: boolean }).partial);
// The bridge's own incompleteness, in the shared vocabulary, read through
// what this query DECLARED. The fan-out above already drops every neighbour
@ -856,6 +896,7 @@ export async function runGroupImpact(
const bridge = crossRepoCompleteness({
unreadableRepos: bridgePrep.meta.unreadableRepos,
missingRepos: bridgePrep.meta.missingRepos,
suppressedMatchStages: bridgePrep.meta.suppressedMatchStages,
provenanceUnknown,
inScope: (candidate) =>
repoInSubgroup(candidate, subgroup) || repoInSubgroup(candidate, repoPath, true),
@ -878,15 +919,23 @@ export async function runGroupImpact(
// and under-reporting a blast radius is the unsafe direction (an agent told
// LOW proceeds; told CRITICAL it stops). Marking the floor keeps the
// warning intact while making the incompleteness legible.
// Runtime limits first — they are what the caller can retry. 'incomplete-sync'
// is the remaining cause once nothing was merely cut short, and its remedy is
// a different one: re-run `gitnexus group sync`, not the query. Computed
// inline because `truncationFields` reads the reason ONLY on the truncated
// branch — naming it in a variable invited reading it on the complete path,
// where it would say 'incomplete-sync' about a complete result.
// Runtime limits first — they are what the caller can retry. Past those, the
// BRIDGE's own reason wins: it already distinguished an unreadable repo
// ('incomplete-sync', remedy: re-sync) from a stage the sync was asked to
// skip ('suppressed-stage', remedy: re-sync WITHOUT the flag). Hardcoding
// the fallback here overrode that and told every caller to repair a repo
// that read fine — and made the second value unreachable from this surface
// while the tool description promised it. `cross-trace.ts` re-spreads the
// bridge's fields for the same reason.
...truncationFields(
truncated,
fanoutTimedOut ? 'timeout' : runtimeTruncated ? 'partial' : 'incomplete-sync',
fanoutTimedOut
? 'timeout'
: runtimeTruncated
? 'partial'
: bridge.truncated
? bridge.truncationReason
: 'incomplete-sync',
),
truncatedRepos: [...new Set([...truncatedRepos, ...bridge.incompleteRepos])],
summary: {
@ -896,6 +945,7 @@ export async function runGroupImpact(
cross_repo_hits: cross.length,
},
risk: mergeRisk(localRisk, cross),
...liftLocalRiskMeta(local, cross),
timeoutMs,
crossDepthWarning,
};

View file

@ -264,6 +264,7 @@ function bridgeCompletenessFor(
return crossRepoCompleteness({
unreadableRepos: meta.unreadableRepos,
missingRepos: meta.missingRepos,
suppressedMatchStages: meta.suppressedMatchStages,
provenanceUnknown: bridgeProvenanceUnknown(meta),
inScope,
});

View file

@ -21,3 +21,94 @@ export function readSafe(repoPath: string, rel: string): string | null {
return null;
}
}
/** Read a regular in-repo file without buffering more than `maxBytes`. */
export async function readSafeBounded(
repoPath: string,
rel: string,
maxBytes: number,
): Promise<string | null> {
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) return null;
const abs = path.resolve(repoPath, rel);
const base = path.resolve(repoPath);
const relToBase = path.relative(base, abs);
if (relToBase.startsWith('..') || path.isAbsolute(relToBase)) return null;
try {
const canonicalBase = await fs.promises.realpath(base);
const canonicalFile = await fs.promises.realpath(abs);
const canonicalRelative = path.relative(canonicalBase, canonicalFile);
if (canonicalRelative.startsWith('..') || path.isAbsolute(canonicalRelative)) return null;
const beforeOpen = await fs.promises.lstat(canonicalFile);
if (!beforeOpen.isFile() || beforeOpen.size > maxBytes) return null;
if (maxBytes === 0) return beforeOpen.size === 0 ? '' : null;
return await new Promise<string | null>((resolve) => {
const stream = fs.createReadStream(canonicalFile, {
flags: 'r',
start: 0,
end: maxBytes,
autoClose: true,
});
const chunks: Buffer[] = [];
let totalBytes = 0;
let validated = false;
let settled = false;
const finish = (value: string | null): void => {
if (settled) return;
settled = true;
resolve(value);
};
stream.pause();
stream.once('open', (fd) => {
try {
const opened = fs.fstatSync(fd);
if (!opened.isFile() || opened.size > maxBytes) {
finish(null);
stream.destroy();
return;
}
const currentCanonical = fs.realpathSync(canonicalFile);
const currentRelative = path.relative(canonicalBase, currentCanonical);
const current = fs.statSync(currentCanonical);
if (
currentRelative.startsWith('..') ||
path.isAbsolute(currentRelative) ||
opened.dev !== current.dev ||
opened.ino !== current.ino
) {
finish(null);
stream.destroy();
return;
}
validated = true;
stream.resume();
} catch {
finish(null);
stream.destroy();
}
});
stream.on('data', (chunk: Buffer | string) => {
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
totalBytes += bytes.length;
if (totalBytes > maxBytes) {
finish(null);
stream.destroy();
return;
}
chunks.push(bytes);
});
stream.once('end', () => {
finish(validated ? Buffer.concat(chunks, totalBytes).toString('utf8') : null);
});
stream.once('error', () => finish(null));
stream.once('close', () => finish(null));
});
} catch {
return null;
}
}

View file

@ -0,0 +1,707 @@
import { glob } from 'glob';
import {
Kind,
parse,
type DocumentNode,
type FragmentDefinitionNode,
type OperationDefinitionNode,
type SelectionSetNode,
} from 'graphql';
import Parser from 'tree-sitter';
import TypeScript from 'tree-sitter-typescript';
import { createIgnoreFilter } from '../../../config/ignore-service.js';
import { getMaxFileSizeBytes } from '../../ingestion/utils/max-file-size.js';
import { logger } from '../../logger.js';
import { ParseTimeoutError, parseSourceSafe } from '../../tree-sitter/safe-parse.js';
import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js';
import type { ExtractedContract, RepoHandle } from '../types.js';
import { readSafeBounded } from './fs-utils.js';
const PROVIDER_GLOB = '**/*.{ts,tsx,mts,cts}';
const DOCUMENT_GLOB = '**/*.{graphql,gql}';
const NEST_GRAPHQL_PACKAGE = '@nestjs/graphql';
const MAX_GRAPHQL_TOKENS = 100_000;
const MAX_GRAPHQL_DEFINITIONS = 5_000;
const MAX_GRAPHQL_OPERATIONS = 500;
const MAX_GRAPHQL_SELECTIONS = 10_000;
const MAX_GRAPHQL_TRAVERSAL_DEPTH = 64;
const MAX_PROVIDER_AST_NODES = 100_000;
const MAX_PROVIDER_AST_DEPTH = 256;
const GRAPHQL_NAME = /^[_A-Za-z][_0-9A-Za-z]*$/;
type GraphqlOperationKind = 'query' | 'mutation' | 'subscription';
interface ResolvedSymbol {
uid: string;
name: string;
filePath: string;
}
interface DecoratorBindings {
operations: Map<string, GraphqlOperationKind>;
resolvers: Set<string>;
}
type DecoratorFieldName =
| { kind: 'absent' }
| { kind: 'literal'; value: string }
| { kind: 'dynamic' };
type GeneratedSymbolIndex = Map<string, Parser.SyntaxNode[]>;
type GeneratedIndexCache = Map<string, Promise<GeneratedSymbolIndex | null>>;
export const RESOLVE_METHOD_QUERY = `
MATCH (n)
WHERE labels(n) IN ['Method','Function','Property','CodeElement']
AND n.name = $name AND n.filePath = $filePath AND n.startLine = $startLine AND n.id <> ''
RETURN n.id AS uid, n.name AS name, n.filePath AS filePath
ORDER BY n.id ASC
LIMIT 2`;
// LadybugDB returns labels(n) as a scalar string, not Neo4j's string array.
// The real-db integration test executes this exact query and guards that dialect contract.
export const RESOLVE_GENERATED_SYMBOL_QUERY = `
MATCH (n)
WHERE labels(n) IN ['Const','Variable','Function','Method','CodeElement']
AND n.name = $name AND n.filePath <> '' AND n.id <> ''
RETURN n.id AS uid, n.name AS name, n.filePath AS filePath
ORDER BY n.id ASC
LIMIT 2`;
function rowValue(row: Record<string, unknown>, key: string, position: number): string {
return String(row[key] ?? row[position] ?? '');
}
function uniqueRealSymbol(rows: Record<string, unknown>[]): ResolvedSymbol | null {
if (rows.length !== 1) return null;
const row = rows[0];
const symbol = {
uid: rowValue(row, 'uid', 0),
name: rowValue(row, 'name', 1),
filePath: rowValue(row, 'filePath', 2).replace(/\\/g, '/'),
};
return symbol.uid && symbol.name && symbol.filePath ? symbol : null;
}
function unquote(text: string): string | null {
const trimmed = text.trim();
if (trimmed.length < 2) return null;
const quote = trimmed[0];
if ((quote !== "'" && quote !== '"' && quote !== '`') || trimmed.at(-1) !== quote) return null;
const value = trimmed.slice(1, -1);
return value.includes('${') ? null : value;
}
function unwrapExpression(node: Parser.SyntaxNode): Parser.SyntaxNode {
let current = node;
while (
['as_expression', 'satisfies_expression', 'parenthesized_expression'].includes(current.type) &&
current.namedChildren[0]
) {
current = current.namedChildren[0];
}
return current;
}
function objectPairValue(node: Parser.SyntaxNode, key: string): Parser.SyntaxNode | null {
const object = unwrapExpression(node);
if (object.type !== 'object') return null;
for (const pair of object.namedChildren) {
if (pair.type !== 'pair') continue;
const keyNode = pair.childForFieldName('key');
const pairKey = keyNode ? (unquote(keyNode.text) ?? keyNode.text) : null;
if (pairKey === key) return pair.childForFieldName('value');
}
return null;
}
function literalValue(node: Parser.SyntaxNode | null): string | null {
return node ? unquote(unwrapExpression(node).text) : null;
}
function graphqlNameValue(node: Parser.SyntaxNode | null): string | null {
return node ? literalValue(objectPairValue(node, 'value')) : null;
}
function withinGeneratedAstBudget(root: Parser.SyntaxNode): boolean {
const pending: Array<{ node: Parser.SyntaxNode; depth: number }> = [{ node: root, depth: 0 }];
let visited = 0;
while (pending.length > 0) {
const current = pending.pop();
if (!current) break;
visited++;
if (visited > MAX_PROVIDER_AST_NODES || current.depth > MAX_PROVIDER_AST_DEPTH) return false;
for (let index = current.node.namedChildren.length - 1; index >= 0; index--) {
const child = current.node.namedChildren[index];
if (child) pending.push({ node: child, depth: current.depth + 1 });
}
}
return true;
}
function generatedRootFields(
selectionSet: Parser.SyntaxNode | null,
fragments: ReadonlyMap<string, Parser.SyntaxNode>,
): Set<string> | null {
const fields = new Set<string>();
if (!selectionSet) return null;
const seenFragments = new Set<string>();
const pending: Array<{ selectionSet: Parser.SyntaxNode; depth: number }> = [
{ selectionSet, depth: 0 },
];
let selectionsVisited = 0;
while (pending.length > 0) {
const current = pending.pop();
if (!current) break;
if (current.depth > MAX_GRAPHQL_TRAVERSAL_DEPTH) return null;
const selections = objectPairValue(current.selectionSet, 'selections');
const array = selections ? unwrapExpression(selections) : null;
if (!array || array.type !== 'array') return null;
for (const item of array.namedChildren) {
selectionsVisited++;
if (selectionsVisited > MAX_GRAPHQL_SELECTIONS) return null;
const selection = unwrapExpression(item);
const kind = literalValue(objectPairValue(selection, 'kind'));
if (kind === 'Field') {
const field = graphqlNameValue(objectPairValue(selection, 'name'));
if (field) fields.add(field);
continue;
}
if (kind === 'InlineFragment') {
const nested = objectPairValue(selection, 'selectionSet');
if (nested) pending.push({ selectionSet: nested, depth: current.depth + 1 });
continue;
}
if (kind !== 'FragmentSpread') continue;
const name = graphqlNameValue(objectPairValue(selection, 'name'));
if (!name || seenFragments.has(name)) continue;
const fragment = fragments.get(name);
if (!fragment) continue;
const nested = objectPairValue(fragment, 'selectionSet');
if (!nested) continue;
seenFragments.add(name);
pending.push({ selectionSet: nested, depth: current.depth + 1 });
}
}
return fields;
}
function parsedDocumentProof(
source: string,
operationKind: GraphqlOperationKind,
operationName: string,
requiredFields: readonly string[],
): boolean {
let document: DocumentNode;
try {
document = parse(source, { noLocation: true, maxTokens: MAX_GRAPHQL_TOKENS });
} catch {
return false;
}
if (document.definitions.length > MAX_GRAPHQL_DEFINITIONS) return false;
const fragments = new Map<string, FragmentDefinitionNode>();
for (const definition of document.definitions) {
if (definition.kind === Kind.FRAGMENT_DEFINITION)
fragments.set(definition.name.value, definition);
}
for (const definition of document.definitions) {
if (definition.kind !== Kind.OPERATION_DEFINITION) continue;
if (definition.operation !== operationKind || definition.name?.value !== operationName)
continue;
const fields = rootFields(definition.selectionSet, fragments);
return fields !== null && requiredFields.every((field) => fields.includes(field));
}
return false;
}
function staticGraphqlSource(initializer: Parser.SyntaxNode): string | null {
const value = unwrapExpression(initializer);
if (value.type === 'string') {
if (value.text.startsWith('"')) {
try {
return JSON.parse(value.text) as string;
} catch {
return null;
}
}
return unquote(value.text);
}
if (value.type === 'template_string') return unquote(value.text);
if (value.type === 'call_expression') {
const template = value.namedChildren.find((child) => child.type === 'template_string');
return template ? unquote(template.text) : null;
}
if (value.type !== 'new_expression') return null;
const constructor = value.childForFieldName('constructor') ?? value.namedChildren[0];
if (!constructor || !constructor.text.endsWith('TypedDocumentString')) return null;
const args = value.childForFieldName('arguments');
const first = args?.namedChildren[0];
return first ? staticGraphqlSource(first) : null;
}
export function hasGeneratedDocumentProof(
initializer: Parser.SyntaxNode,
operationKind: GraphqlOperationKind,
operationName: string,
requiredFields: readonly string[],
): boolean {
if (!withinGeneratedAstBudget(initializer)) return false;
const staticSource = staticGraphqlSource(initializer);
if (staticSource !== null) {
return parsedDocumentProof(staticSource, operationKind, operationName, requiredFields);
}
const document = unwrapExpression(initializer);
if (literalValue(objectPairValue(document, 'kind')) !== 'Document') return false;
const definitions = objectPairValue(document, 'definitions');
const array = definitions ? unwrapExpression(definitions) : null;
if (!array || array.type !== 'array') return false;
const fragments = new Map<string, Parser.SyntaxNode>();
for (const item of array.namedChildren) {
const definition = unwrapExpression(item);
if (literalValue(objectPairValue(definition, 'kind')) !== 'FragmentDefinition') continue;
const name = graphqlNameValue(objectPairValue(definition, 'name'));
if (name) fragments.set(name, definition);
}
for (const item of array.namedChildren) {
const definition = unwrapExpression(item);
if (literalValue(objectPairValue(definition, 'kind')) !== 'OperationDefinition') continue;
if (literalValue(objectPairValue(definition, 'operation')) !== operationKind) continue;
if (graphqlNameValue(objectPairValue(definition, 'name')) !== operationName) continue;
const fields = generatedRootFields(objectPairValue(definition, 'selectionSet'), fragments);
if (fields && requiredFields.every((field) => fields.has(field))) return true;
}
return false;
}
function importedDecoratorBindings(root: Parser.SyntaxNode): DecoratorBindings {
const operations = new Map<string, GraphqlOperationKind>();
const resolvers = new Set<string>();
for (const child of root.namedChildren) {
if (child.type !== 'import_statement') continue;
const source = child.childForFieldName('source');
if (!source || unquote(source.text) !== NEST_GRAPHQL_PACKAGE) continue;
const namedImports = child.namedChildren
.find((node) => node.type === 'import_clause')
?.namedChildren.find((node) => node.type === 'named_imports');
if (!namedImports) continue;
for (const specifier of namedImports.namedChildren) {
if (specifier.type !== 'import_specifier') continue;
const imported = specifier.childForFieldName('name')?.text;
const local = specifier.childForFieldName('alias')?.text ?? imported;
if (!imported || !local) continue;
const kind = imported.toLowerCase();
if (kind === 'query' || kind === 'mutation' || kind === 'subscription') {
operations.set(local, kind);
} else if (imported === 'Resolver') {
resolvers.add(local);
}
}
}
return { operations, resolvers };
}
function decoratorKind(
decorator: Parser.SyntaxNode,
bindings: Map<string, GraphqlOperationKind>,
): { kind: GraphqlOperationKind; argumentsNode?: Parser.SyntaxNode } | null {
const expression = decorator.namedChildren[0];
if (!expression) return null;
if (expression.type === 'identifier') {
const kind = bindings.get(expression.text);
return kind ? { kind } : null;
}
if (expression.type !== 'call_expression') return null;
const callee = expression.childForFieldName('function');
if (!callee || callee.type !== 'identifier') return null;
const kind = bindings.get(callee.text);
if (!kind) return null;
return { kind, argumentsNode: expression.childForFieldName('arguments') ?? undefined };
}
function decoratorFieldName(argumentsNode: Parser.SyntaxNode | undefined): DecoratorFieldName {
if (!argumentsNode || argumentsNode.namedChildren.length === 0) return { kind: 'absent' };
const args = argumentsNode.namedChildren;
if (args[0] && ['string', 'template_string'].includes(args[0].type)) {
const direct = unquote(args[0].text);
return direct === null ? { kind: 'dynamic' } : { kind: 'literal', value: direct };
}
let sawOptions = false;
for (const arg of args) {
if (arg.type !== 'object') continue;
sawOptions = true;
for (const pair of arg.namedChildren) {
if (pair.type === 'spread_element' || pair.type.startsWith('shorthand_property_identifier')) {
return { kind: 'dynamic' };
}
if (pair.type !== 'pair') continue;
const key = pair.childForFieldName('key')?.text.replace(/^['"]|['"]$/g, '');
if (key !== 'name') continue;
const value = pair.childForFieldName('value');
if (!value || !['string', 'template_string'].includes(value.type)) {
return { kind: 'dynamic' };
}
const literal = unquote(value.text);
return literal === null ? { kind: 'dynamic' } : { kind: 'literal', value: literal };
}
}
if (sawOptions || args.length === 1) return { kind: 'absent' };
return { kind: 'dynamic' };
}
function topLevelResolverClassBodies(
root: Parser.SyntaxNode,
resolverBindings: ReadonlySet<string>,
): Parser.SyntaxNode[] | null {
if (!withinGeneratedAstBudget(root)) return null;
const bodies: Parser.SyntaxNode[] = [];
for (const statement of root.namedChildren) {
const classNode =
statement.type === 'class_declaration'
? statement
: statement.type === 'export_statement'
? statement.namedChildren.find((child) => child.type === 'class_declaration')
: undefined;
if (!classNode) continue;
const decorators = [
...new Set([
...statement.namedChildren.filter((child) => child.type === 'decorator'),
...classNode.namedChildren.filter((child) => child.type === 'decorator'),
]),
];
const isResolver = decorators.some((decorator) => {
const expression = decorator.namedChildren[0];
if (!expression) return false;
const callee =
expression.type === 'call_expression'
? expression.childForFieldName('function')
: expression;
return callee?.type === 'identifier' && resolverBindings.has(callee.text);
});
if (!isResolver) continue;
const body = classNode.childForFieldName('body');
if (body) bodies.push(body);
}
return bodies;
}
function rootFields(
selectionSet: SelectionSetNode,
fragments: ReadonlyMap<string, FragmentDefinitionNode>,
): string[] | null {
const fields: string[] = [];
const seenFragments = new Set<string>();
const pending: Array<{ selectionSet: SelectionSetNode; depth: number }> = [
{ selectionSet, depth: 0 },
];
let selectionsVisited = 0;
while (pending.length > 0) {
const current = pending.pop();
if (!current) break;
if (current.depth > MAX_GRAPHQL_TRAVERSAL_DEPTH) return null;
for (const selection of current.selectionSet.selections) {
selectionsVisited++;
if (selectionsVisited > MAX_GRAPHQL_SELECTIONS) return null;
if (selection.kind === Kind.FIELD) {
fields.push(selection.name.value);
continue;
}
if (selection.kind === Kind.INLINE_FRAGMENT) {
pending.push({ selectionSet: selection.selectionSet, depth: current.depth + 1 });
continue;
}
const name = selection.name.value;
if (seenFragments.has(name)) continue;
const fragment = fragments.get(name);
if (!fragment) continue;
seenFragments.add(name);
pending.push({ selectionSet: fragment.selectionSet, depth: current.depth + 1 });
}
}
return fields;
}
function generatedCandidates(operation: OperationDefinitionNode): string[] {
const name = operation.name?.value;
return name ? [`${name}Document`] : [];
}
async function generatedDocumentMatches(
repoPath: string,
symbol: ResolvedSymbol,
operationKind: GraphqlOperationKind,
operationName: string,
requiredFields: readonly string[],
cache: GeneratedIndexCache,
): Promise<boolean> {
const normalizedPath = symbol.filePath.replace(/\\/g, '/');
let pendingIndex = cache.get(normalizedPath);
if (!pendingIndex) {
pendingIndex = buildGeneratedSymbolIndex(repoPath, normalizedPath);
cache.set(normalizedPath, pendingIndex);
}
const index = await pendingIndex;
const values = index?.get(symbol.name) ?? [];
return values.some((value) =>
hasGeneratedDocumentProof(value, operationKind, operationName, requiredFields),
);
}
async function buildGeneratedSymbolIndex(
repoPath: string,
filePath: string,
): Promise<GeneratedSymbolIndex | null> {
const source = await readSafeBounded(repoPath, filePath, getMaxFileSizeBytes());
if (source === null) return null;
const parser = new Parser();
parser.setLanguage(
filePath.toLowerCase().endsWith('.tsx') ? TypeScript.tsx : TypeScript.typescript,
);
let tree: Parser.Tree;
try {
tree = parseSourceSafe(parser, source, undefined, undefined, filePath);
} catch (error) {
if (error instanceof ParseTimeoutError) return null;
throw error;
}
return indexGeneratedDeclarators(tree.rootNode);
}
export function indexGeneratedDeclarators(root: Parser.SyntaxNode): GeneratedSymbolIndex {
const index: GeneratedSymbolIndex = new Map();
const pending = [root];
while (pending.length > 0) {
const node = pending.pop();
if (!node) break;
if (node.type === 'variable_declarator') {
const name = node.childForFieldName('name')?.text;
const value = node.childForFieldName('value');
if (name && value) {
const values = index.get(name) ?? [];
values.push(value);
index.set(name, values);
}
}
for (let child = node.namedChildren.length - 1; child >= 0; child--) {
pending.push(node.namedChildren[child]);
}
}
return index;
}
function dedupe(contracts: ExtractedContract[]): ExtractedContract[] {
const seen = new Set<string>();
return contracts.filter((contract) => {
const key = `${contract.contractId}|${contract.role}|${contract.symbolUid}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
export class GraphqlExtractor implements ContractExtractor {
type = 'graphql' as const;
async canExtract(_repo: RepoHandle): Promise<boolean> {
return true;
}
async extract(
dbExecutor: CypherExecutor | null,
repoPath: string,
_repo: RepoHandle,
): Promise<ExtractedContract[]> {
if (!dbExecutor) return [];
const ignore = await createIgnoreFilter(repoPath);
const [providerFiles, documentFiles] = await Promise.all([
glob(PROVIDER_GLOB, { cwd: repoPath, ignore, nodir: true }),
glob(DOCUMENT_GLOB, { cwd: repoPath, ignore, nodir: true }),
]);
const contracts = [
...(await this.extractProviders(dbExecutor, repoPath, providerFiles)),
...(await this.extractConsumers(dbExecutor, repoPath, documentFiles)),
];
return dedupe(contracts);
}
private async extractProviders(
dbExecutor: CypherExecutor,
repoPath: string,
files: string[],
): Promise<ExtractedContract[]> {
const parser = new Parser();
const contracts: ExtractedContract[] = [];
const maxFileSizeBytes = getMaxFileSizeBytes();
for (const rel of files) {
if (/\.(?:spec|test)\.[cm]?tsx?$/i.test(rel)) continue;
const source = await readSafeBounded(repoPath, rel, maxFileSizeBytes);
if (source === null || !source.includes(NEST_GRAPHQL_PACKAGE)) continue;
parser.setLanguage(
rel.toLowerCase().endsWith('.tsx') ? TypeScript.tsx : TypeScript.typescript,
);
let tree: Parser.Tree;
try {
tree = parseSourceSafe(parser, source, undefined, undefined, rel);
} catch (error) {
if (error instanceof ParseTimeoutError) continue;
throw error;
}
const bindings = importedDecoratorBindings(tree.rootNode);
if (bindings.operations.size === 0 || bindings.resolvers.size === 0) continue;
const bodies = topLevelResolverClassBodies(tree.rootNode, bindings.resolvers);
if (bodies === null) continue;
for (const body of bodies) {
let decorators: Parser.SyntaxNode[] = [];
for (const member of body.namedChildren) {
if (member.type === 'comment') continue;
if (member.type === 'decorator') {
decorators.push(member);
continue;
}
if (member.type !== 'method_definition' && member.type !== 'public_field_definition') {
decorators = [];
continue;
}
const memberDecorators = [
...new Set([
...decorators,
...member.namedChildren.filter((child) => child.type === 'decorator'),
]),
];
const methodName = member.childForFieldName('name')?.text;
if (!methodName) {
decorators = [];
continue;
}
for (const decorator of memberDecorators) {
const operation = decoratorKind(decorator, bindings.operations);
if (!operation) continue;
const parsedField = decoratorFieldName(operation.argumentsNode);
if (parsedField.kind === 'dynamic') continue;
const field = parsedField.kind === 'literal' ? parsedField.value : methodName;
if (!GRAPHQL_NAME.test(field)) continue;
const filePath = rel.replace(/\\/g, '/');
const symbol = uniqueRealSymbol(
await dbExecutor(RESOLVE_METHOD_QUERY, {
name: methodName,
filePath,
startLine:
member.type === 'public_field_definition'
? (member.childForFieldName('value')?.startPosition.row ??
member.startPosition.row) + 1
: member.startPosition.row + 1,
}),
);
if (!symbol) continue;
contracts.push({
contractId: `graphql::${operation.kind}::${field}`,
type: 'graphql',
role: 'provider',
symbolUid: symbol.uid,
symbolRef: { filePath: symbol.filePath, name: symbol.name },
symbolName: symbol.name,
confidence: 1,
meta: {
operationKind: operation.kind,
fieldName: field,
resolverPath: filePath,
extractionStrategy: 'nestjs_decorator',
},
});
}
decorators = [];
}
}
}
return contracts;
}
private async extractConsumers(
dbExecutor: CypherExecutor,
repoPath: string,
files: string[],
): Promise<ExtractedContract[]> {
const contracts: ExtractedContract[] = [];
const generatedIndexCache: GeneratedIndexCache = new Map();
const maxFileSizeBytes = getMaxFileSizeBytes();
for (const rel of files) {
const source = await readSafeBounded(repoPath, rel, maxFileSizeBytes);
if (source === null) continue;
let document: DocumentNode;
try {
document = parse(source, { noLocation: true, maxTokens: MAX_GRAPHQL_TOKENS });
} catch (error) {
logger.debug({ file: rel, error }, 'skipping invalid GraphQL document');
continue;
}
if (document.definitions.length > MAX_GRAPHQL_DEFINITIONS) continue;
const fragments = new Map<string, FragmentDefinitionNode>();
for (const definition of document.definitions) {
if (definition.kind === Kind.FRAGMENT_DEFINITION) {
fragments.set(definition.name.value, definition);
}
}
const operations = document.definitions.filter(
(definition): definition is OperationDefinitionNode =>
definition.kind === Kind.OPERATION_DEFINITION && definition.name !== undefined,
);
if (operations.length > MAX_GRAPHQL_OPERATIONS) continue;
for (const definition of operations) {
const operationName = definition.name?.value;
if (!operationName) continue;
const documentPath = rel.replace(/\\/g, '/');
const operationFields = rootFields(definition.selectionSet, fragments);
if (operationFields === null) continue;
const uniqueFields = [...new Set(operationFields)];
let symbol: ResolvedSymbol | null = null;
for (const candidate of generatedCandidates(definition)) {
const resolved = uniqueRealSymbol(
await dbExecutor(RESOLVE_GENERATED_SYMBOL_QUERY, { name: candidate }),
);
if (
resolved &&
(await generatedDocumentMatches(
repoPath,
resolved,
definition.operation,
operationName,
uniqueFields,
generatedIndexCache,
))
) {
symbol = resolved;
break;
}
}
if (!symbol) continue;
for (const field of uniqueFields) {
contracts.push({
contractId: `graphql::${definition.operation}::${field}`,
type: 'graphql',
role: 'consumer',
symbolUid: symbol.uid,
symbolRef: { filePath: symbol.filePath, name: symbol.name },
symbolName: symbol.name,
confidence: 1,
meta: {
operationKind: definition.operation,
operationName: definition.name.value,
fieldName: field,
documentPath,
extractionStrategy: 'graphql_ast',
},
});
}
}
}
return contracts;
}
}

View file

@ -18,6 +18,19 @@ import {
joinPath,
type SharedSpringType,
} from '../../../ingestion/route-extractors/spring-shared.js';
import {
buildKotlinConstantIndex,
extractKotlinModuleConstants,
foldKotlinOperands,
isKotlinConstantFile,
overlayKotlinConstantIndex,
parseKotlinConstOperands,
unfoldableDeclarationsOf,
unquoteKotlinIdentifier,
type KotlinConstantIndex,
type ModuleConstants,
type RepoConstants,
} from '../../../ingestion/route-extractors/kotlin-const-resolver.js';
import {
REST_TEMPLATE_TO_HTTP,
WEB_CLIENT_SHORT_TO_HTTP,
@ -42,6 +55,24 @@ import {
* named annotation arguments (`@GetMapping(value = "/x")` and
* `@GetMapping(path = "/x")`) are supported.
*
* A method path that is a CONSTANT rather than a literal —
* `@GetMapping(ApiPaths.ORDERS)`, `@PostMapping(value = ApiPaths.BASE + "/create")` —
* is folded against a repo-wide Kotlin constant map built once per `extract()`
* run by `prepareRepo`, mirroring what the Java plugin does for the same shape
* in `java.ts`. An unresolvable fold skips the route (never a guessed path), and
* a class prefix that resolves to NO literal at all suppresses every method
* route under that class — the rule `java.ts` applies too, because emitting
* those routes unprefixed would publish paths the application does not serve.
* A prefix that resolves only PARTLY (Kotlin's vararg spelling
* `@RequestMapping("/lit", ApiPaths.BASE)`) still publishes its resolvable arm:
* suppression exists to avoid wrong routes, not to discard right ones. An EMPTY
* path array (`@RequestMapping(arrayOf())`) is not a prefix at all and
* suppresses nothing — see `classifyPathArgument`. On a
* `@FeignClient` the same rule is applied to whichever prefix GOVERNS, in the
* "path wins" order the URL is assembled in — `@FeignClient(path)` first, then
* the interface's `@RequestMapping` — and to both consumer lanes, `@(Get|...)Mapping`
* and `@RequestLine`.
*
* **Consumers** — four call-site patterns common in Kotlin
* Spring projects:
*
@ -131,6 +162,180 @@ const arrayOfArg = (cap: string): string => `(call_expression
(simple_identifier) @arrayOf (#eq? @arrayOf "arrayOf")
(call_suffix (value_arguments (value_argument (string_literal) ${cap}))))`;
/**
* Expression node types a METHOD route path can be FOLDED from. A
* `string_literal` is deliberately absent: literal paths are already captured by
* the dedicated literal patterns, so admitting one here would emit the same
* route twice.
*
* This is an allow-list on purpose, and only safe because it gates FOLDING: a
* shape missing from it yields no route, which is the skip floor. The
* unfoldable-CLASS-PREFIX analysis must not be written this way — there a shape
* missing from the list means "emit unprefixed", a wrong route — so it inverts
* the test instead (see `classifyPathArgument`).
*/
const FOLDABLE_PATH_EXPRESSIONS: ReadonlySet<string> = new Set([
'simple_identifier',
'navigation_expression',
'additive_expression',
]);
/**
* Repo-relative path in the POSIX form the Kotlin constant map is keyed by.
*
* The orchestrator's file list comes from glob v13, which has no `posix: true`
* option and joins with the platform separator, so on Windows `prepareRepo`
* receives `src\main\kotlin\com\example\ApiPaths.kt` and `scan` receives the
* same for `fileRel`. `resolveKotlinImport` turns an import specifier into
* `com/example/ApiPaths.kt` and asks whether a key ENDS WITH it — a test no
* backslashed key can pass. Left unnormalized, every cross-file constant fold
* returns null on Windows and on Windows only: the pre-pass still runs, the
* context is still built, and the feature is simply, silently absent. The unit
* fixtures build POSIX keys by hand, so CI cannot see it.
*
* Normalizing at this boundary — write side (the map keys below) and read side
* (`fileRel`) — is the same fix `node.ts` (`normalizeRel`) and `python.ts`
* (`fileShortKey` / `fileLongKey`) already apply for the same reason, and it is
* the only coherent place: the resolver returns the key it matched, so
* normalizing inside it would hand back a value that misses in a map nobody
* normalized. `readFile` still receives the ORIGINAL `rel`, since the filesystem
* wants the platform's own spelling.
*/
function normalizeRel(rel: string): string {
return rel.replace(/\\/g, '/').replace(/^\.\//, '');
}
/**
* The path expression carried by one route-annotation argument, or null when the
* argument does not designate a path.
*
* tree-sitter-kotlin gives positional and named arguments the same
* `value_argument` node, distinguished only by a leading `simple_identifier` and
* an `=` token — so the key must be read here rather than constrained in the
* query. Non-route keys (`produces`, `consumes`, `headers`, …) return null,
* matching the `#match? @key "^(path|value)$"` guard the literal patterns use.
*/
function kotlinRouteArgumentExpression(arg: Parser.SyntaxNode): Parser.SyntaxNode | null {
const first = arg.namedChild(0);
if (!first) return null;
if (!arg.children.some((c) => c.type === '=')) return first; // positional
if (first.type !== 'simple_identifier') return null;
if (first.text !== 'path' && first.text !== 'value') return null;
return arg.namedChild(1);
}
/**
* The `path = …` expression of one `@FeignClient` argument, or null.
*
* Deliberately narrower than {@link kotlinRouteArgumentExpression}: on a Feign
* client the positional argument and `value =` name a SERVICE, not a path, so
* only the explicit `path` key contributes a URL prefix. This mirrors the
* `#eq? @key "path"` guard the literal `@FeignClient` patterns use, and the
* `keyNode.text !== 'path'` guard `java.ts` applies to the same annotation.
*/
function kotlinFeignPathArgumentExpression(arg: Parser.SyntaxNode): Parser.SyntaxNode | null {
const first = arg.namedChild(0);
if (!first || first.type !== 'simple_identifier') return null;
if (!arg.children.some((c) => c.type === '=')) return null;
if (first.text !== 'path') return null;
return arg.namedChild(1);
}
/**
* Is `node` a string literal whose value is fully known at parse time — that is,
* a literal carrying no interpolation?
*
* tree-sitter-kotlin models `"$base/x"` and `"${base}/x"` as a `string_literal`
* whose named children INTERLEAVE `string_content` runs with interpolation nodes
* — `interpolation_identifier_start`/`interpolated_identifier` for the `$name`
* form, `interpolation_expression_start`/`interpolated_expression`/
* `interpolation_expression_end` for `${…}` — so the test has to be `every`, not
* `some`: `"pre${A.B}post"` carries `string_content` too. The route layer
* unquotes the RAW TEXT, so treating one as a literal publishes the source
* spelling — `/${ApiPaths.BASE}/orders` — as though the application served it.
* Escape sequences are NOT separate nodes in this grammar (`"/a\nb"` is one
* `string_content`), so this accepts exactly what it accepted before; a future
* grammar that split them would floor to "unknown" rather than to a de-escaped
* guess. Same test the constant resolver's `stringLiteralValue` applies, so a
* path is either literal on both sides or folded on neither.
*/
function isPlainStringLiteral(node: Parser.SyntaxNode): boolean {
if (node.type !== 'string_literal') return false;
return node.namedChildren.every((child) => child.type === 'string_content');
}
/**
* Element expressions of a Kotlin `arrayOf(...)` call, or null when `node` is
* not one. The JS mirror of the {@link arrayOfArg} query fragment, so the
* unfoldable-prefix analysis inspects exactly the elements the literal prefix
* patterns harvest.
*/
function kotlinArrayOfElements(node: Parser.SyntaxNode): Parser.SyntaxNode[] | null {
if (node.type !== 'call_expression') return null;
const callee = node.namedChild(0);
if (callee?.type !== 'simple_identifier' || callee.text !== 'arrayOf') return null;
const suffix = node.namedChildren.find((c) => c.type === 'call_suffix');
const args = suffix?.namedChildren.find((c) => c.type === 'value_arguments');
if (!args) return null;
return args.namedChildren
.filter((c) => c.type === 'value_argument')
.map((c) => c.namedChild(0))
.filter((c): c is Parser.SyntaxNode => c !== null);
}
/**
* What a route-annotation path argument says about the prefix it designates.
* Only `'unresolvable'` may suppress a route:
*
* - `'literal'` — at least one element is a plain literal, already harvested by
* the literal prefix patterns, so there is nothing to suppress.
* - `'none'` — no prefix. Empty `arrayOf()` or `[]` is Spring's "map at the root".
* Kept distinct from `'unresolvable'` because conflating them suppressed even
* plain literal routes below such a class, which no constant fold was ever
* involved in. tree-sitter-kotlin (fwcd) represents empty `[]` with a
* zero-width recovery child; filtering it is required for route interfaces,
* which do parse as `class_declaration`.
* - `'unresolvable'` — a non-empty argument with no literal element
* (`ApiPaths.BASE`, `buildPath()`, a template). Served path is unknowable.
*/
type PathArgumentPrefix = 'literal' | 'none' | 'unresolvable';
function classifyPathArgument(expr: Parser.SyntaxNode): PathArgumentPrefix {
if (isPlainStringLiteral(expr)) return 'literal';
if (expr.type === 'collection_literal') {
const elements = expr.namedChildren.filter((child) => child.text.length > 0);
if (elements.length === 0) return 'none';
return elements.some(isPlainStringLiteral) ? 'literal' : 'unresolvable';
}
const elements = kotlinArrayOfElements(expr);
if (elements) {
if (elements.length === 0) return 'none';
return elements.some(isPlainStringLiteral) ? 'literal' : 'unresolvable';
}
return 'unresolvable';
}
/**
* Type declarations enclosing `node`, innermost first, by qualified type path.
*
* The scope a bare constant in a route annotation is resolved against; passed to
* `foldKotlinOperands`, which applies it. Collects `class_declaration` (including
* interfaces) and `object_declaration`. A `companion_object` adds no link of
* its own — members are keyed under the enclosing class one hop up. For a node
* inside `Outer.Inner`, returns `['Outer.Inner', 'Outer']`, matching the keys
* produced by `extractKotlinModuleConstants`. Skips unnamed types rather than
* guessing.
*/
function kotlinEnclosingTypeNames(node: Parser.SyntaxNode): string[] {
const simpleNames: string[] = [];
for (let cur = node.parent; cur; cur = cur.parent) {
if (cur.type !== 'class_declaration' && cur.type !== 'object_declaration') continue;
const ident = cur.children.find((c) => c.type === 'type_identifier');
if (ident) simpleNames.push(unquoteKotlinIdentifier(ident.text));
}
return simpleNames.map((_, index) => simpleNames.slice(index).reverse().join('.'));
}
// ─── Kotlin OkHttp builder verb-walk (parity with java-static-path.ts) ──
// Mirrors `inferOkHttpMethod`, adapted to the Kotlin grammar: a call `X.name(args)`
// is a `call_expression` whose callee is a `navigation_expression` (receiver +
@ -399,6 +604,151 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
],
} satisfies LanguagePatterns<Record<string, never>>);
// ─── Provider: constant-valued @RequestMapping / @(Get|...)Mapping ────
// The literal patterns above pin the path node itself (`(string_literal) @path`),
// which structurally cannot match `@GetMapping(ApiPaths.ORDERS)`. These two
// capture the whole `value_argument` instead and let
// `kotlinRouteArgumentExpression` sort out positional vs `path =`/`value =`
// in JS — a query-level split is not available here, because tree-sitter-kotlin
// uses one `value_argument` node for both forms and 0.21.x has no negation to
// test the `=` token with.
//
// These deliberately match LITERAL arguments too (any `value_argument` does).
// The method-route loop drops those via `FOLDABLE_PATH_EXPRESSIONS` so a
// literal route is emitted once, by the literal patterns; the class-prefix
// collector instead KEEPS them and tests them for literalness, which is how a
// prefix that no literal pattern could resolve gets noticed at all.
const SPRING_CONST_CLASS_PREFIX_PATTERNS = compilePatterns({
name: 'kotlin-spring-const-class-prefix',
language,
patterns: [
{
meta: {},
query: `
(class_declaration
(modifiers
(annotation
(constructor_invocation
(user_type (type_identifier) @ann (#eq? @ann "RequestMapping"))
(value_arguments (value_argument) @arg))))
(type_identifier) @cls) @class
`,
},
],
} satisfies LanguagePatterns<Record<string, never>>);
const SPRING_CONST_METHOD_ROUTE_PATTERNS = compilePatterns({
name: 'kotlin-spring-const-method-route',
language,
patterns: [
{
meta: {},
query: `
(function_declaration
(modifiers
(annotation
(constructor_invocation
(user_type (type_identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Mapping$"))
(value_arguments (value_argument) @arg))))
(simple_identifier) @method_name) @method
`,
},
],
} satisfies LanguagePatterns<Record<string, never>>);
const SPRING_CONST_FEIGN_PATH_PATTERNS = compilePatterns({
name: 'kotlin-spring-const-feign-path',
language,
patterns: [
{
meta: {},
query: `
(class_declaration
(modifiers
(annotation
(constructor_invocation
(user_type (type_identifier) @ann (#eq? @ann "FeignClient"))
(value_arguments (value_argument) @arg))))) @class
`,
},
],
} satisfies LanguagePatterns<Record<string, never>>);
/**
* Ids of classes whose `@RequestMapping` prefix cannot be resolved to any
* literal, so no route under them can be published at a path the application
* actually serves.
*
* The predicate is INVERTED rather than an allow-list of non-literal node
* types: a class is marked unless its `path`/`value` argument is provably
* literal (recursing into `[…]` and `arrayOf(…)` elements, and refusing an
* interpolated `string_literal`). An allow-list has to enumerate every
* non-literal spelling and silently passes the ones it forgot —
* `[ApiPaths.BASE]`, `arrayOf(ApiPaths.BASE)`, `buildPath()`,
* `if (…) "/a" else "/b"` — each of which then publishes its methods at their
* UNPREFIXED path, a route the application does not serve. `java.ts` gates on
* the ABSENCE of a literal (`if (!valueNode)`) for the same reason.
*
* `resolvedPrefixes` is the literal prefix map built by the pass ABOVE, and a
* class holding an entry there is deliberately NOT marked: Kotlin's vararg
* spelling `@RequestMapping("/lit", ApiPaths.BASE)` leaves a resolvable `/lit`
* behind, and suppressing it would drop a route that IS derivable — trading a
* wrong route for a missing one, which is not the bargain this suppression
* exists to make. The prefix set is then partial (the constant arm is absent)
* exactly as it was before constant folding existed.
*
* The prefix is never folded here: it also feeds the cross-file
* interface-inheritance pass, which has no repo context, so folding it in
* `scan` alone would make the two views disagree. Same rule `java.ts` applies
* (`typesWithUnfoldablePrefix`); folding class prefixes cross-file is a
* follow-up on both sides. Used by BOTH `scan` and the inheritance-view
* collector — with the prefix map each has already built — so the two cannot
* drift apart.
*/
const collectUnfoldablePrefixClassIds = (
tree: Parser.Tree,
resolvedPrefixes: ReadonlyMap<number, string[]>,
): Set<number> => {
const ids = new Set<number>();
for (const match of runCompiledPatterns(SPRING_CONST_CLASS_PREFIX_PATTERNS, tree)) {
const argNode = match.captures.arg;
const classNode = match.captures.class;
if (!argNode || !classNode) continue;
if ((resolvedPrefixes.get(classNode.id) ?? []).length > 0) continue;
const expr = kotlinRouteArgumentExpression(argNode);
if (!expr || classifyPathArgument(expr) !== 'unresolvable') continue;
ids.add(classNode.id);
}
return ids;
};
/**
* Ids of `@FeignClient` interfaces whose `path` argument is present but not
* resolvable to a literal.
*
* `collectUnfoldablePrefixClassIds` cannot see these: it matches
* `@RequestMapping` only, so `@FeignClient(path = ApiPaths.BASE)` fell through
* to the `['']` prefix fallback and published the consumer at its unprefixed
* path — a call the service never makes. Kept as its own set rather than
* merged into the `@RequestMapping` one because `path` OUTRANKS
* `@RequestMapping` on a Feign client: an unresolvable `path` is fatal
* whatever the `@RequestMapping` says, and a resolvable `path` rescues a route
* whose `@RequestMapping` is a constant. The consumer lanes therefore consult
* the two in that same "path wins" order.
*/
const collectFeignUnfoldablePathClassIds = (tree: Parser.Tree): Set<number> => {
const ids = new Set<number>();
for (const match of runCompiledPatterns(SPRING_CONST_FEIGN_PATH_PATTERNS, tree)) {
const argNode = match.captures.arg;
const classNode = match.captures.class;
if (!argNode || !classNode) continue;
const expr = kotlinFeignPathArgumentExpression(argNode);
if (!expr || classifyPathArgument(expr) !== 'unresolvable') continue;
ids.add(classNode.id);
}
return ids;
};
// ─── Consumer: Spring RestTemplate ────────────────────────────────────
// Kotlin call-site shape mirrors the Java plugin's
// `REST_TEMPLATE_PATTERNS`, but goes through tree-sitter-kotlin's
@ -875,11 +1225,24 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
const prefixNode = match.captures.prefix;
const classNode = match.captures.class;
if (!prefixNode || !classNode) continue;
// An INTERPOLATED literal (`"${ApiPaths.BASE}"`) is not a path — unquoting
// its raw text would carry the source spelling into the shared type view
// as a served prefix. Refusing it here is also what lets the unfoldable
// analysis below mark such a class (it skips classes with a resolved
// prefix), so the two stay one decision rather than two.
if (!isPlainStringLiteral(prefixNode)) continue;
const prefix = unquoteLiteral(prefixNode.text);
if (prefix !== null) pushPrefix(prefixByClassId, classNode.id, prefix);
}
// Method @(Get|...)Mapping routes keyed by the function_declaration node id.
//
// Only LITERAL paths land here. A constant-valued path is folded in `scan`
// against the repo constant map, which this inheritance-view collector has
// no access to; publishing it as an empty path would put `POST /`-shaped
// noise into the shared type view, so it is left out — the same skip floor
// `java.ts`'s `collectSpringTypes` keeps.
const routesByMethodId = new Map<number, Array<{ method: string; path: string }>>();
const unfoldablePrefixClassIds = collectUnfoldablePrefixClassIds(tree, prefixByClassId);
for (const match of runCompiledPatterns(SPRING_METHOD_ROUTE_PATTERNS, tree)) {
const annNode = match.captures.ann;
const pathNode = match.captures.path;
@ -889,6 +1252,10 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
if (!httpMethod) continue;
const rawPath = unquoteLiteral(pathNode.text);
if (rawPath === null) continue;
// A constant class prefix leaves no single prefix string for the
// inheritance view to carry, so this route would be published unprefixed.
const owner = findEnclosingClass(methodNode);
if (owner && unfoldablePrefixClassIds.has(owner.id)) continue;
const arr = routesByMethodId.get(methodNode.id) ?? [];
arr.push({ method: httpMethod, path: rawPath });
routesByMethodId.set(methodNode.id, arr);
@ -931,8 +1298,90 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
return {
name: 'kotlin-http',
language,
scan(tree) {
prepareRepo(args) {
// Build the repo-wide Kotlin string-constant map and import index once per
// extract() run. The orchestrator hands over a bare Parser with no language
// bound; bind Kotlin explicitly or `parseSource` spins to its whole time
// budget on every file.
try {
args.parser.setLanguage(language);
} catch {
// A parser that rejects binding cannot produce a constant map; the
// per-file try/catch below then skips everything harmlessly.
}
const constants = new Map<string, ModuleConstants>();
for (const rel of args.files) {
if (!rel.endsWith('.kt') && !rel.endsWith('.kts')) continue;
try {
const src = args.readFile(rel);
// Cheap content gate: only constant-DEFINITION candidates are parsed
// here. Import-only files (every controller) are deliberately NOT
// parsed in this pass — `scan` extracts the importing file's own
// import table from the tree it already holds, on demand, for the
// rare file that actually references a constant. A gate that also
// matched `import …` would parse the entire repository here.
if (!src || !isKotlinConstantFile(src)) continue;
const tree = args.parseSource(args.parser, src);
if (!tree) continue;
const mc = extractKotlinModuleConstants(tree);
if (
mc.literals.size > 0 ||
mc.exprs.size > 0 ||
mc.imports.size > 0 ||
unfoldableDeclarationsOf(mc).size > 0
) {
// POSIX key (see `normalizeRel`); `readFile` above got the raw `rel`.
constants.set(normalizeRel(rel), mc);
}
} catch {
// Per-file resilience: one unreadable/oversized/ill-formed file must
// not forfeit the whole repo's constant map.
continue;
}
}
return { constants, index: buildKotlinConstantIndex(constants) };
},
scan(tree, repoContext, fileRel) {
const out: HttpDetection[] = [];
const kotlinCtx = repoContext as
| { constants: RepoConstants; index: KotlinConstantIndex }
| undefined;
// Read side of the POSIX keying (see `normalizeRel`): the map `prepareRepo`
// built is keyed by normalized path, so every lookup and every fold entry
// point below uses `fileKey`, never the raw `fileRel`.
const fileKey = fileRel === undefined ? undefined : normalizeRel(fileRel);
// Lazy per-file constants/index view. `prepareRepo` only indexes constant-
// DEFINING files, so an importing controller is absent from that map. When
// a route references a constant, extract THIS file's import table from the
// tree `scan` already holds and overlay it. Import-only overlays reuse the
// prepared package projections; files whose routes are all literal never
// pay this cost.
let foldIndex: KotlinConstantIndex | undefined;
const getFoldIndex = (): KotlinConstantIndex | undefined => {
if (foldIndex !== undefined) return foldIndex;
foldIndex = kotlinCtx?.index;
if (!kotlinCtx || !fileKey) return foldIndex;
if (kotlinCtx.constants.has(fileKey)) return foldIndex;
try {
const mc = extractKotlinModuleConstants(tree);
// Same admission test the pre-pass applies above. Keeping the complete
// test here also makes this overlay correct if a future gate safely
// excludes another declaration shape.
if (
mc.literals.size > 0 ||
mc.exprs.size > 0 ||
mc.imports.size > 0 ||
unfoldableDeclarationsOf(mc).size > 0
) {
foldIndex = overlayKotlinConstantIndex(kotlinCtx.index, fileKey, mc);
}
} catch {
// fold falls back to the repo-wide map (imports stay unresolved)
}
return foldIndex;
};
// ─── Class prefixes ─────────────────────────────────────────────
const prefixByClassId = new Map<number, string[]>();
@ -940,10 +1389,17 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
const prefixNode = match.captures.prefix;
const classNode = match.captures.class;
if (!prefixNode || !classNode) continue;
// An INTERPOLATED literal (`"${ApiPaths.BASE}"`) is not a path — see
// `isPlainStringLiteral`. Refusing it here also lets the unfoldable
// analysis below mark such a class, since that skips classes whose
// prefix already resolved.
if (!isPlainStringLiteral(prefixNode)) continue;
const prefix = unquoteLiteral(prefixNode.text);
if (prefix !== null) pushPrefix(prefixByClassId, classNode.id, prefix);
}
const classesWithUnfoldablePrefix = collectUnfoldablePrefixClassIds(tree, prefixByClassId);
// ─── OpenFeign client interfaces + HTTP Interface type prefixes ──
// In tree-sitter-kotlin an `interface` is a `class_declaration`, so a
// `@FeignClient` interface's @(Get|...)Mapping methods would otherwise be
@ -956,11 +1412,12 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
if (!classNode) continue;
feignClassIds.add(classNode.id);
const prefixNode = match.captures.prefix;
if (prefixNode) {
if (prefixNode && isPlainStringLiteral(prefixNode)) {
const prefix = unquoteLiteral(prefixNode.text);
if (prefix !== null) pushPrefix(feignPrefixByClassId, classNode.id, prefix);
}
}
const feignClassesWithUnfoldablePath = collectFeignUnfoldablePathClassIds(tree);
const httpExchangePrefixByClassId = new Map<number, string[]>();
for (const match of runCompiledPatterns(SPRING_HTTP_EXCHANGE_CLASS_PATTERNS, tree)) {
const classNode = match.captures.class;
@ -971,24 +1428,91 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
}
// ─── Method routes (Spring providers) + OpenFeign consumers ─────
// Literal and constant-valued paths are normalized into one candidate list
// so both reach the same Feign/interface/prefix classification below.
const methodRoutes: Array<{
httpMethod: string;
rawPath: string;
nameNode: Parser.SyntaxNode | undefined;
methodNode: Parser.SyntaxNode;
}> = [];
for (const match of runCompiledPatterns(SPRING_METHOD_ROUTE_PATTERNS, tree)) {
const annNode = match.captures.ann;
const pathNode = match.captures.path;
const nameNode = match.captures.method_name;
const methodNode = match.captures.method;
if (!annNode || !pathNode || !methodNode) continue;
const httpMethod = METHOD_ANNOTATION_TO_HTTP[annNode.text];
if (!httpMethod) continue;
const rawPath = unquoteLiteral(pathNode.text);
if (rawPath === null) continue;
methodRoutes.push({
httpMethod,
rawPath,
nameNode: match.captures.method_name,
methodNode,
});
}
for (const match of runCompiledPatterns(SPRING_CONST_METHOD_ROUTE_PATTERNS, tree)) {
const annNode = match.captures.ann;
const argNode = match.captures.arg;
const methodNode = match.captures.method;
if (!annNode || !argNode || !methodNode) continue;
const httpMethod = METHOD_ANNOTATION_TO_HTTP[annNode.text];
if (!httpMethod) continue;
const expr = kotlinRouteArgumentExpression(argNode);
if (!expr || !FOLDABLE_PATH_EXPRESSIONS.has(expr.type)) continue;
// No repo context (context-less fallback scanning) means no constant map
// and therefore no honest answer — skip rather than guess a path.
if (!fileKey) continue;
const index = getFoldIndex();
if (!index) continue;
const operands = parseKotlinConstOperands(expr);
if (operands === null) continue;
// A bare reference means whatever the ENCLOSING types bind it to before
// it means anything at file level — Kotlin's rule for a companion
// member, which is in scope unqualified only inside its own class body.
const rawPath = foldKotlinOperands(
fileKey,
operands,
index.repo,
kotlinEnclosingTypeNames(methodNode),
index,
);
if (rawPath === null) continue;
methodRoutes.push({
httpMethod,
rawPath,
nameNode: match.captures.method_name,
methodNode,
});
}
for (const { httpMethod, rawPath, nameNode, methodNode } of methodRoutes) {
const enclosingClass = findEnclosingClass(methodNode);
// A @(Get|...)Mapping inside a @FeignClient interface is an OpenFeign
// consumer (a remote call), not a route this service serves.
if (enclosingClass && feignClassIds.has(enclosingClass.id)) {
// Whichever prefix GOVERNS must be resolvable, or the remote URL is
// unknowable and an unprefixed consumer would be a call this service
// never makes. Checked in the same "path wins" order the fallback
// below resolves in, so an unresolvable `@RequestMapping` does not
// suppress a client whose literal `@FeignClient(path)` outranks it,
// and an unresolvable `path` is fatal even when `@RequestMapping` is
// a literal.
//
// This reaches a Feign INTERFACE at all because tree-sitter-kotlin
// models `interface` as a `class_declaration`, and it should: Spring
// Cloud prepends the governing prefix to every method of the client.
// Java diverges only by accident of its grammar — `findEnclosingClass`
// skips `interface_declaration`, so `java.ts` still emits such a
// consumer at its unprefixed path. Aligning Java is a change to Java's
// behavior and belongs in its own follow-up, not in the Kotlin binding.
if (feignClassesWithUnfoldablePath.has(enclosingClass.id)) continue;
const feignPrefixes = feignPrefixByClassId.get(enclosingClass.id);
if (!feignPrefixes && classesWithUnfoldablePrefix.has(enclosingClass.id)) continue;
// @FeignClient(path) wins over @RequestMapping; a multi-element prefix
// yields one consumer per (prefix × this route).
const prefixes = feignPrefixByClassId.get(enclosingClass.id) ??
prefixByClassId.get(enclosingClass.id) ?? [''];
const prefixes = feignPrefixes ?? prefixByClassId.get(enclosingClass.id) ?? [''];
for (const prefix of prefixes) {
out.push({
role: 'consumer',
@ -1002,6 +1526,10 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
}
continue;
}
// An unresolvable class prefix leaves no path this service serves, so
// every route under such a class is dropped rather than emitted at a
// wrong (unprefixed) one — the rule `java.ts` applies for Java.
if (enclosingClass && classesWithUnfoldablePrefix.has(enclosingClass.id)) continue;
// A @(Get|...)Mapping on a (non-Feign) interface declares a route
// *contract*, not a route this service serves — the implementing
// @RestController is the provider, emitted via scanProject's interface
@ -1171,13 +1699,21 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
if (!parsed) continue;
const enclosingClass = findEnclosingClass(methodNode);
if (!enclosingClass || !isKotlinInterface(enclosingClass)) continue;
// The same governing-prefix resolvability guard the @(Get|...)Mapping-in-Feign
// lane applies, in the same "path wins" order — this loop resolves through
// the identical fallback chain, so an unresolvable governing prefix leaves
// the remote URL just as unknowable here. Without it a single interface
// could suppress its @(Get|...)Mapping routes and publish its @RequestLine
// routes under the very same unresolvable prefix.
if (feignClassesWithUnfoldablePath.has(enclosingClass.id)) continue;
const feignPrefixes = feignPrefixByClassId.get(enclosingClass.id);
if (!feignPrefixes && classesWithUnfoldablePrefix.has(enclosingClass.id)) continue;
// Mirror java.ts (which pre-merges the @RequestMapping fallback into
// feignPrefixByInterfaceId, "path wins"): @FeignClient(path) wins, else
// the interface's class-level @RequestMapping prefix, else none. Without
// the prefixByClassId fallback Kotlin dropped the class prefix that Java
// applies — the same fallback chain the @GetMapping-in-Feign path uses above.
const prefixes = feignPrefixByClassId.get(enclosingClass.id) ??
prefixByClassId.get(enclosingClass.id) ?? [''];
const prefixes = feignPrefixes ?? prefixByClassId.get(enclosingClass.id) ?? [''];
for (const prefix of prefixes) {
out.push({
role: 'consumer',

View file

@ -15,6 +15,8 @@ import {
DATA_ROUTE_TABLE_SOURCE,
scanDataRouteTables,
} from '../../../ingestion/route-extractors/data-route-table.js';
import { extractNestRoutes } from '../../../ingestion/route-extractors/nest.js';
import { normalizeExtractedRoutePath } from '../../../ingestion/route-extractors/route-path.js';
import {
buildJsRepoFacts,
extractJsModuleFacts,
@ -27,7 +29,8 @@ import {
/**
* Node.js / TypeScript HTTP plugin family. Handles:
* - NestJS `@Controller('prefix')` classes with `@Get(':id')` methods
* - NestJS `@Controller('prefix')` classes with `@Get(':id')` methods,
* delegated wholesale to the indexer's `extractNestRoutes`
* - Express `router.get(...)` / `app.post(...)` providers
* - `fetch(url)` / `fetch(url, { method: 'POST' })` consumers
* - `axios.get(url)` / `axios.delete(url)` consumers
@ -42,34 +45,8 @@ import {
* same `scan` function but bind to different grammars.
*/
// ─── Provider: NestJS — class-level @Controller('prefix') ────────────
// In tree-sitter-typescript decorators are NOT children of
// class_declaration / method_definition — they're siblings in the
// surrounding class_body / program node. We therefore match the
// decorator standalone and walk to its related class/method in JS.
const NEST_CONTROLLER_SPEC: PatternSpec<Record<string, never>> = {
meta: {},
query: `
(decorator
(call_expression
function: (identifier) @dec (#eq? @dec "Controller")
arguments: (arguments . [(string) (template_string)] @prefix))) @ctrl_decorator
`,
};
// ─── Provider: NestJS — method-level @Get/@Post/... decorators ───────
// Matches either `@Get('path')` or `@Get()`. The `@path` capture is
// optional — when the first argument isn't a string, the plugin falls
// back to '/' for the method-level path.
const NEST_METHOD_SPEC: PatternSpec<Record<string, never>> = {
meta: {},
query: `
(decorator
(call_expression
function: (identifier) @dec (#match? @dec "^(Get|Post|Put|Delete|Patch)$")
arguments: (arguments) @args)) @method_decorator
`,
};
// NestJS providers are not queried here at all — see the `extractNestRoutes`
// call in `scanBundle`.
// ─── Provider: Express — router.get/app.post/... ─────────────────────
const EXPRESS_SPEC: PatternSpec<Record<string, never>> = {
@ -176,8 +153,6 @@ const AXIOS_OBJECT_SPEC: PatternSpec<Record<string, never>> = {
};
interface NodePatternBundle {
controller: CompiledPatterns<Record<string, never>>;
methodDecorator: CompiledPatterns<Record<string, never>>;
express: CompiledPatterns<Record<string, never>>;
fetchNoOptions: CompiledPatterns<Record<string, never>>;
fetchWithOptions: CompiledPatterns<Record<string, never>>;
@ -195,8 +170,6 @@ function compileBundle(language: unknown, name: string): NodePatternBundle {
patterns: [spec],
} satisfies LanguagePatterns<Record<string, never>>);
return {
controller: mk(NEST_CONTROLLER_SPEC, 'nest-controller'),
methodDecorator: mk(NEST_METHOD_SPEC, 'nest-method-decorator'),
express: mk(EXPRESS_SPEC, 'express'),
fetchNoOptions: mk(FETCH_NO_OPTIONS_SPEC, 'fetch-no-options'),
fetchWithOptions: mk(FETCH_WITH_OPTIONS_SPEC, 'fetch-with-options'),
@ -211,33 +184,6 @@ const JAVASCRIPT_BUNDLE = compileBundle(JavaScript, 'javascript-http');
const TYPESCRIPT_BUNDLE = compileBundle(TypeScript.typescript, 'typescript-http');
const TSX_BUNDLE = compileBundle(TypeScript.tsx, 'tsx-http');
const NEST_DECORATOR_TO_HTTP: Record<string, string> = {
Get: 'GET',
Post: 'POST',
Put: 'PUT',
Delete: 'DELETE',
Patch: 'PATCH',
};
/**
* Find the nearest enclosing class_declaration for a node, or null.
*/
function findEnclosingClass(node: Parser.SyntaxNode): Parser.SyntaxNode | null {
let cur: Parser.SyntaxNode | null = node.parent;
while (cur) {
if (cur.type === 'class_declaration') return cur;
cur = cur.parent;
}
return null;
}
function joinPath(prefix: string, sub: string): string {
const cleanPrefix = prefix.replace(/^\/+/, '').replace(/\/+$/, '');
const cleanSub = sub.replace(/^\/+/, '');
if (!cleanPrefix) return `/${cleanSub}`;
return `/${cleanPrefix}/${cleanSub}`;
}
/**
* Walk `pair` children of an `object` literal and return the unquoted
* string/template_string value for the first pair whose key matches one
@ -260,68 +206,6 @@ function readStringProp(objectNode: Parser.SyntaxNode, keyNames: readonly string
return null;
}
/**
* For a standalone `decorator` node (child of class_body / program),
* find the related `class_declaration` node that it decorates. In
* tree-sitter-typescript the decorator is placed before the class
* declaration as a sibling (when decorating a class) or inside the
* class_body before a method_definition (when decorating a method);
* we walk the parent chain until we find the enclosing class.
*/
function findDecoratedClass(decoratorNode: Parser.SyntaxNode): Parser.SyntaxNode | null {
const parent = decoratorNode.parent;
if (!parent) return null;
// Case 1: decorator is a sibling of the class_declaration at program /
// export_statement level. Walk forward through siblings until we find
// the class_declaration this decorator belongs to.
for (let i = 0; i < parent.namedChildCount; i++) {
const child = parent.namedChild(i);
if (child && child.id === decoratorNode.id) {
for (let j = i + 1; j < parent.namedChildCount; j++) {
const next = parent.namedChild(j);
if (!next) continue;
if (next.type === 'decorator') continue; // adjacent decorators stack
if (next.type === 'class_declaration') return next;
if (next.type === 'export_statement') {
// `export class Foo { ... }` wraps the declaration.
for (let k = 0; k < next.namedChildCount; k++) {
const inner = next.namedChild(k);
if (inner?.type === 'class_declaration') return inner;
}
}
break;
}
break;
}
}
// Case 2: decorator is inside a class_body (decorating a method) —
// walk up to the enclosing class_declaration.
return findEnclosingClass(decoratorNode);
}
/**
* For a method-level decorator node (child of class_body before a
* method_definition), find the method_definition it decorates.
*/
function findDecoratedMethod(decoratorNode: Parser.SyntaxNode): Parser.SyntaxNode | null {
const parent = decoratorNode.parent;
if (!parent || parent.type !== 'class_body') return null;
for (let i = 0; i < parent.namedChildCount; i++) {
const child = parent.namedChild(i);
if (child && child.id === decoratorNode.id) {
for (let j = i + 1; j < parent.namedChildCount; j++) {
const next = parent.namedChild(j);
if (!next) continue;
if (next.type === 'decorator') continue;
if (next.type === 'method_definition') return next;
return null;
}
return null;
}
}
return null;
}
/**
* Map each named import's LOCAL binding to its DECLARED export name and source
* module, by walking the file's `import { x as y } from 'm'` statements. Lets
@ -589,57 +473,33 @@ function scanBundle(
// symbol resolves to the real definition rather than its local alias text.
const importMap = buildImportMap(tree);
// NestJS: collect `@Controller('prefix')` class decorators, keyed by
// the `class_declaration` they decorate.
const prefixByClassId = new Map<number, string>();
for (const match of runCompiledPatterns(bundle.controller, tree)) {
const prefixNode = match.captures.prefix;
const decoratorNode = match.captures.ctrl_decorator;
if (!prefixNode || !decoratorNode) continue;
const prefix = unquoteLiteral(prefixNode.text);
if (prefix === null) continue;
const classNode = findDecoratedClass(decoratorNode);
if (!classNode) continue;
prefixByClassId.set(classNode.id, prefix);
}
// NestJS: method-level @Get/@Post/... decorators. The decorator's
// arguments list may be empty (`@Get()`), a string (`@Get('path')`),
// or something else (which we skip).
for (const match of runCompiledPatterns(bundle.methodDecorator, tree)) {
const decNode = match.captures.dec;
const argsNode = match.captures.args;
const decoratorNode = match.captures.method_decorator;
if (!decNode || !argsNode || !decoratorNode) continue;
const httpMethod = NEST_DECORATOR_TO_HTTP[decNode.text];
if (!httpMethod) continue;
const methodNode = findDecoratedMethod(decoratorNode);
if (!methodNode) continue;
const enclosingClass = findEnclosingClass(methodNode);
// Only emit NestJS detections when the class actually has a
// @Controller decorator — without it, the match is almost certainly
// something else (e.g. an unrelated library using similar names).
if (!enclosingClass || !prefixByClassId.has(enclosingClass.id)) continue;
const prefix = prefixByClassId.get(enclosingClass.id) ?? '';
let rawPath = '/';
const firstArg = argsNode.namedChild(0);
if (firstArg && (firstArg.type === 'string' || firstArg.type === 'template_string')) {
const unquoted = unquoteLiteral(firstArg.text);
if (unquoted !== null) rawPath = unquoted;
}
// Get the method name from the decorated method_definition.
const methodNameNode = methodNode.childForFieldName('name');
const name = methodNameNode?.text ?? null;
// NestJS: delegated to the indexer's extractor rather than re-queried here.
// Two independent readings of the same decorators is how the layers drift:
// the local scan saw only `class_declaration` (never `abstract class`), only
// five of the nine verbs, only a positional string `@Controller('x')`, and
// — worst — INVENTED `/` for a method path it could not read, so
// `@Get(ROUTES.SEARCH)` became a `GET /venues` contract that the graph, which
// correctly drops it, has no Route node for. "A missing route is a coverage
// limit; an invented one is a lie" (ARCHITECTURE.md). Calling the extractor
// makes that divergence structurally impossible, exactly as the
// `scanDataRouteTables` call below already does for static route tables.
//
// `filePath` rides only on the returned struct and never reaches the
// `HttpDetection`, so a bare `scan(tree)` with no `fileRel` passes '' rather
// than losing the routes. `lineOffset` is 0: the group scanner parses whole
// files, so `lineNumber` is already the absolute 1-based line this
// `HttpDetection.line` wants.
for (const route of extractNestRoutes(tree, fileRel ?? '', 0)) {
out.push({
role: 'provider',
framework: 'nest',
method: httpMethod,
path: joinPath(prefix, rawPath),
name,
line: methodNode.startPosition.row + 1,
method: route.httpMethod,
// The prefix travels separately at the ingestion layer, so the join is
// ours to do — with ingestion's own joiner, so the two layers cannot
// disagree about the URL either.
path: normalizeExtractedRoutePath(route.routePath, route.prefix ?? null),
name: route.handlerName ?? null,
line: route.lineNumber,
confidence: 0.8,
});
}

View file

@ -15,20 +15,36 @@ import type { HttpDetection, HttpLanguagePlugin } from './types.js';
* Providers:
* - Laravel `Route::get/post/...`
*
* Consumers (string-literal URLs only):
* Consumers (string-literal URLs only, unless noted):
* - Laravel HTTP client: `Http::get/post/put/delete/patch($url)`
* - Guzzle / generic object method: `$client->get/post/...($url)`
* - `file_get_contents($url)`
* - `new Request($method, $host . $resourcePath)` — the openapi-generator-php
* / swagger-codegen client shape. `$resourcePath` is resolved via a
* single-scope backward constant fold (see `resolveLocalStringLiteral`),
* not a string literal at the call site itself.
*
* The pipeline already uses `PHP.php_only` for ingesting plain `.php`
* files (see `core/tree-sitter/parser-loader.ts`), and we do the same
* here so Laravel route files are parsed with the right grammar dialect.
*
* Scope notes: consumer patterns match string literals only. URLs built
* via binary concatenation (`$base . '/path'`), `sprintf`, or config
* lookup (`config('services.foo.base').'/path'`) are intentionally left
* for a follow-up — they require constant-folding the surrounding
* scope to be meaningful.
* Scope notes: consumer patterns match string literals only, with one
* narrow exception (above). URLs built via `sprintf`, config lookup
* (`config('services.foo.base').'/path'`), or a variable resolved from
* outside its own function/method body are intentionally left for a
* follow-up — they require constant-folding beyond one local scope to
* be meaningful.
*
* That narrow exception (`resolveLocalStringLiteral`) is a temporary,
* single-scope fallback, not this language's entry into the shared
* cross-file constant-fold used by the other languages in this plugin
* (`constant-resolver.ts`, wired in via `java-const-resolver.ts` /
* `python-const-resolver.ts` / `js-const-resolver.ts`). PHP has no such
* binding yet — adding one is a real, separate project (this repo's PHP
* import resolution for `use`-statements is its own multi-file subsystem
* under `ingestion/import-resolvers/php.ts`, built for symbol/scope
* resolution, not constant extraction) and is intentionally out of scope
* here. Tracked as a follow-up, not silently punted.
*/
const LARAVEL_ROUTE_SPEC: PatternSpec<Record<string, never>> = {
@ -71,11 +87,31 @@ const FILE_GET_CONTENTS_SPEC: PatternSpec<Record<string, never>> = {
`,
};
/**
* `new Request($method, $host . $resourcePath)` — the shape swagger-codegen /
* openapi-generator-php emit for every operation of a generated API client
* (Guzzle's `\GuzzleHttp\Psr7\Request`, or a bare `Request` behind a `use`
* import). Matches both `(name)` and `(qualified_name)` class references;
* `scan()` below filters to the last path segment being exactly `Request`
* and resolves the concatenated path argument (see `resolveLocalStringLiteral`).
*/
const GUZZLE_REQUEST_CTOR_SPEC: PatternSpec<Record<string, never>> = {
meta: {},
query: `
(object_creation_expression
[(name) (qualified_name)] @class
(arguments
. (argument (_) @methodArg)
. (argument (_) @pathArg)))
`,
};
interface PhpPatternBundle {
laravelRoute: CompiledPatterns<Record<string, never>>;
httpFacade: CompiledPatterns<Record<string, never>>;
guzzleMember: CompiledPatterns<Record<string, never>>;
fileGetContents: CompiledPatterns<Record<string, never>>;
guzzleRequestCtor: CompiledPatterns<Record<string, never>>;
}
const mk = (spec: PatternSpec<Record<string, never>>, suffix: string) =>
@ -90,6 +126,7 @@ const PHP_PATTERNS: PhpPatternBundle = {
httpFacade: mk(HTTP_FACADE_SPEC, 'http-facade'),
guzzleMember: mk(GUZZLE_MEMBER_SPEC, 'guzzle-member'),
fileGetContents: mk(FILE_GET_CONTENTS_SPEC, 'file-get-contents'),
guzzleRequestCtor: mk(GUZZLE_REQUEST_CTOR_SPEC, 'guzzle-request-ctor'),
};
/**
@ -129,6 +166,183 @@ function isHttpUrlLiteral(path: string): boolean {
return path.startsWith('http://') || path.startsWith('https://');
}
/**
* Last identifier segment of a class-name reference: `(name)` returns its
* own text, `(qualified_name)` returns the text of its last child (the
* unqualified class name — `\GuzzleHttp\Psr7\Request` → `Request`).
*/
function lastNameSegment(node: import('tree-sitter').SyntaxNode): string {
if (node.type === 'qualified_name') {
const last = node.child(node.childCount - 1);
return last ? last.text : node.text;
}
return node.text;
}
/**
* Return the variable at the LAST position of a `.`-concatenation
* expression, if (and only if) that position is a plain variable —
* generated clients build `<host> . <resourcePath>`, so the path segment
* is the one closest to the end.
*
* No fallback to an earlier operand: if the rightmost position is anything
* other than a variable, a parenthesized sub-expression, or a nested `.`
* concatenation (a literal, a function call, ...), that position is a real
* value we simply can't resolve — falling back to an EARLIER operand would
* silently substitute a different value (e.g. the host) for the one that's
* actually there. `null` here is a miss, not a signal to keep looking.
*/
function lastConcatVariable(
node: import('tree-sitter').SyntaxNode,
): import('tree-sitter').SyntaxNode | null {
if (node.type === 'variable_name') return node;
if (node.type === 'parenthesized_expression') {
const inner = node.namedChild(0);
return inner ? lastConcatVariable(inner) : null;
}
if (node.type === 'binary_expression') {
const operator = node.childForFieldName('operator');
if (!operator || operator.text !== '.') return null; // not concatenation
const right = node.childForFieldName('right');
return right ? lastConcatVariable(right) : null;
}
return null;
}
/**
* True if `node`'s subtree assigns to `$target` ANYWHERE inside it, at any
* depth (including inside nested functions — deliberately over-broad: a
* false positive here only costs a miss in the caller, never a wrong
* answer, so there's no need to be precise about scoping inside the probe
* itself).
*/
function containsAssignmentTo(node: import('tree-sitter').SyntaxNode, target: string): boolean {
if (node.type === 'assignment_expression') {
const lhs = node.childForFieldName('left');
if (lhs && lhs.type === 'variable_name' && lhs.text === target) return true;
}
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child && containsAssignmentTo(child, target)) return true;
}
return false;
}
/**
* True if an `anonymous_function` node's `use (...)` clause lists
* `$target`. PHP closures capture NOTHING automatically — only variables
* named in `use (...)` are visible inside — unlike arrow functions
* (`fn() => ...`), which auto-capture everything by value and have no
* `compound_statement` body of their own, so they're never seen as a
* `scope` by the walk below in the first place.
*/
function anonymousFunctionCaptures(
anonFn: import('tree-sitter').SyntaxNode,
target: string,
): boolean {
for (let i = 0; i < anonFn.namedChildCount; i++) {
const child = anonFn.namedChild(i);
if (!child || child.type !== 'anonymous_function_use_clause') continue;
for (let j = 0; j < child.namedChildCount; j++) {
const v = child.namedChild(j);
if (v && v.type === 'variable_name' && v.text === target) return true;
}
return false; // has a use(...) clause, but $target isn't in it
}
return false; // no use(...) clause at all — nothing is captured
}
/**
* Best-effort, single-scope constant fold: given a `variable_name` node
* referenced inside a `new Request(...)` argument, walk BACKWARD through
* the preceding statements of its immediately enclosing function/method
* body (or file scope, for top-level script code) looking for the nearest
* `$var = '<literal>';` assignment.
*
* "Enclosing body" is resolved level by level, not just the nearest
* `compound_statement` — a call site nested in `if`/`foreach`/`try` inside
* that function is still within the same function/method body, and a
* preceding assignment above that conditional must still be found. Each
* level searches only its own preceding siblings, then the search
* continues from the enclosing block itself one level up, UNLESS that
* block IS the body of a function/method/closure:
* - a regular `function_definition` or `method_declaration` boundary
* always stops the search — PHP gives a function or method no access
* to anything outside its own body (no automatic capture, no implicit
* global), so widening past one into the containing class or
* file-level scope would resolve a variable the call site could never
* actually see at runtime;
* - an `anonymous_function` boundary stops UNLESS `$target` is
* explicitly captured via `use (...)` — closures capture nothing
* automatically either.
* It stops at `program` regardless, for the case where the call site was
* at file/script scope all along.
*
* A preceding sibling that ISN'T a plain assignment but might reassign the
* target somewhere inside itself (an `if`/`foreach`/`try`/`switch`, ...)
* stops the search rather than being skipped over: whether that branch ran
* is unknown, so an older literal further back can't be trusted either.
*
* Deliberately conservative and bounded — no interprocedural resolution,
* no constant/property lookups. A miss just means the endpoint stays
* undetected, never a wrong one: this is exactly the class of case the
* module docblock flags as in-scope only for one local scope.
*/
function resolveLocalStringLiteral(varNode: import('tree-sitter').SyntaxNode): string | null {
const target = varNode.text; // includes the `$` sigil, e.g. "$resourcePath"
let cursor: import('tree-sitter').SyntaxNode = varNode;
for (;;) {
let scope: import('tree-sitter').SyntaxNode | null = cursor.parent;
while (scope && scope.type !== 'compound_statement' && scope.type !== 'program') {
scope = scope.parent;
}
if (!scope) return null;
let stmt: import('tree-sitter').SyntaxNode | null = cursor;
while (stmt && stmt.parent !== scope) stmt = stmt.parent;
if (!stmt) return null;
let sibling = stmt.previousNamedSibling;
while (sibling) {
if (sibling.type === 'expression_statement') {
const inner = sibling.namedChild(0);
if (inner && inner.type === 'assignment_expression') {
const lhs = inner.childForFieldName('left');
if (lhs && lhs.type === 'variable_name' && lhs.text === target) {
// The NEAREST assignment to this variable wins, full stop — an
// older literal further back is shadowed by this one even when
// this one isn't itself a resolvable string (`$v = f();`).
const rhs = inner.childForFieldName('right');
return rhs && rhs.type === 'string' ? phpStringText(rhs) : null;
}
}
} else if (containsAssignmentTo(sibling, target)) {
return null; // reassigned somewhere inside a conditional/loop/try
}
sibling = sibling.previousNamedSibling;
}
if (scope.type === 'program') return null;
const enclosing = scope.parent;
if (enclosing && enclosing.type === 'anonymous_function') {
// Closures capture nothing automatically — only what's use()'d.
if (!anonymousFunctionCaptures(enclosing, target)) return null;
} else if (
enclosing &&
(enclosing.type === 'function_definition' || enclosing.type === 'method_declaration')
) {
// A regular function or method boundary — NOT a closure. PHP gives
// these no access to anything outside their own body (no automatic
// capture, no implicit global): widening past one into the
// containing class body or file-level scope would resolve a
// variable the call site could never actually see at runtime.
return null;
}
cursor = scope; // one block up: search resumes from this block's own position
}
}
export const PHP_HTTP_PLUGIN: HttpLanguagePlugin = {
name: 'php-http',
language: PHP.php_only,
@ -136,12 +350,16 @@ export const PHP_HTTP_PLUGIN: HttpLanguagePlugin = {
// ingestion, so the graph is authoritative for PHP providers (#2138 Part 2).
routeCoverage: 'complete',
// Consumer signals scan() can detect: Laravel `Http::<verb>`, Guzzle client
// `->get/post/.../request(...)`, and `file_get_contents` of an HTTP URL. A
// provider-covered file with any of these must still be parsed (ingestion
// emits no FETCHES for PHP). Conservative — the `->verb(` shape over-matches
// ordinary method calls, which only costs a parse, never data.
// `->get/post/.../request(...)`, `file_get_contents` of an HTTP URL, and a
// generated-client `new ...Request(...)` constructor call. A provider-covered
// file with any of these must still be parsed (ingestion emits no FETCHES for
// PHP). Conservative — the `->verb(`/`new ...Request(` shapes over-match
// ordinary method calls and unrelated constructors, which only costs a
// parse, never data.
hasConsumerSignals(content) {
return /Http::|file_get_contents|->\s*(get|post|put|delete|patch|request)\s*\(/i.test(content);
return /Http::|file_get_contents|->\s*(get|post|put|delete|patch|request)\s*\(|new\s+[\\\w]*Request\s*\(/i.test(
content,
);
},
scan(tree) {
const out: HttpDetection[] = [];
@ -222,6 +440,62 @@ export const PHP_HTTP_PLUGIN: HttpLanguagePlugin = {
});
}
for (const match of runCompiledPatterns(PHP_PATTERNS.guzzleRequestCtor, tree)) {
const classNode = match.captures.class;
const methodArg = match.captures.methodArg;
const pathArg = match.captures.pathArg;
if (!classNode || !methodArg || !pathArg) continue;
// PHP class names are case-insensitive at the language level, and
// `hasConsumerSignals` above matches case-insensitively (`/i`) for
// the same reason — this comparison must agree with it, or a valid
// `new request(...)` / `new \NS\REQUEST(...)` call would be waved
// through the parse-skip gate as a signal and then silently dropped
// here.
if (lastNameSegment(classNode).toLowerCase() !== 'request') continue;
// Path: a direct string literal, or the last variable in a
// concatenation chain (see `lastConcatVariable`) resolved to a
// locally-assigned literal.
let path: string | null = null;
if (pathArg.type === 'string') {
path = phpStringText(pathArg);
} else {
const lastVar = lastConcatVariable(pathArg);
path = lastVar ? resolveLocalStringLiteral(lastVar) : null;
}
if (path === null || !isHttpClientPath(path)) continue;
// The HTTP verb is a literal, a local variable resolved the same way
// as the path (see `resolveLocalStringLiteral` above), or — commonly
// in generated clients — a parameter of the enclosing builder method
// fixed by ITS caller, not by this call site. That last case needs
// the same interprocedural reach the module docblock rules out, so it
// falls through to a wildcard verb, matching this project's own
// convention for a contract whose verb isn't pinned (see manifest
// links, `http::*::`).
let method: string | null = null;
if (methodArg.type === 'string') {
method = phpStringText(methodArg);
} else if (methodArg.type === 'variable_name') {
method = resolveLocalStringLiteral(methodArg);
}
out.push({
role: 'consumer',
framework: 'guzzle-request-ctor',
method: method ? method.toUpperCase() : '*',
path,
name: null,
// Line of the path ARGUMENT, not the `new Request(` call — same
// choice the other three consumer patterns in this file make, but
// this is the one pattern where the two routinely differ (generated
// clients wrap the call across multiple lines). Line-span
// containment still resolves to the right symbol either way.
line: pathArg.startPosition.row + 1,
confidence: 0.6,
});
}
return out;
},
};

View file

@ -1,4 +1,9 @@
import type { ContractType, CrossLink, GroupManifestLink, StoredContract } from '../types.js';
import type {
CrossLink,
GroupManifestLink,
ManifestContractType,
StoredContract,
} from '../types.js';
import type { CypherExecutor } from '../contract-extractor.js';
import { logger } from '../../logger.js';
@ -366,11 +371,11 @@ export class ManifestExtractor {
* equality matching without requiring wildcard logic downstream.
*
* NOTE on exhaustiveness: the switch covers every current
* `ContractType` variant and falls through to a `never` assertion so
* manifest-declared contract type and falls through to a `never` assertion so
* TypeScript fails the build if a new variant is added without a
* corresponding case.
*/
private buildContractId(type: ContractType, contract: string): string {
private buildContractId(type: ManifestContractType, contract: string): string {
switch (type) {
case 'http': {
// Canonicalize method casing and path separators so logically

View file

@ -37,7 +37,13 @@ function buildNoisyContractFilter(
: new Set<string>();
const excludeParamOnly = matchingConfig?.exclude_links_param_only_paths === true;
return function isNoisyHttpContract(contractId: string): boolean {
return function isNoisyContract(contractId: string): boolean {
if (contractId.startsWith('graphql::')) {
const parts = contractId.split('::');
if (parts.length < 3) return false;
const field = parts.slice(2).join('::');
return excludePaths.has(field) || excludePaths.has(`/${field}`);
}
if (!contractId.startsWith('http::')) return false;
const parts = contractId.split('::');
if (parts.length < 3) return false;

View file

@ -15,7 +15,7 @@ import {
type RepoMeta,
} from '../../storage/repo-manager.js';
import { crossRepoCompleteness } from './completeness.js';
import { recordedRepoList } from './completeness.js';
import { recordedMatchStages, recordedRepoList } from './completeness.js';
import { GroupNotFoundError, loadGroupConfig } from './config-parser.js';
import {
fileMatchesServicePrefix,
@ -262,7 +262,8 @@ function registryIdentifies(entries: RegistryEntry[], registryName: string): boo
async function loadContractRegistryResilient(
groupDir: string,
): Promise<
{ ok: true; registry: ContractRegistry; skippedCorrupt: number } | { ok: false; error: string }
| { ok: true; registry: ContractRegistry; skippedCorrupt: number; suppressionUnreadable: boolean }
| { ok: false; error: string }
> {
const filePath = path.join(groupDir, 'contracts.json');
let raw: string;
@ -327,6 +328,15 @@ async function loadContractRegistryResilient(
// Bound once: the gate is a full array scan and the ternary below used it twice.
const recordedUnreadable = recordedRepoList(base.unreadableRepos);
const recordedSuppressed = recordedMatchStages(base.suppressedMatchStages);
// Present-but-unreadable is NOT the same as absent. `recordedMatchStages` is
// all-or-nothing, so garbage collapses to `undefined` — and a consumer that
// reads `undefined` as "nothing was suppressed" would throw that safety away
// and report a registry it could not parse as complete. Absent stays
// legitimate (a registry predating the field); only a value that was there
// and unreadable forces the answer to a floor.
const suppressionUnreadable =
base.suppressedMatchStages !== undefined && recordedSuppressed === undefined;
const registry: ContractRegistry = {
version: typeof base.version === 'number' ? base.version : 0,
generatedAt: typeof base.generatedAt === 'string' ? base.generatedAt : '',
@ -348,11 +358,75 @@ async function loadContractRegistryResilient(
// rendered as a clean result, which is the same conflation this whole
// change removes.
...(recordedUnreadable ? { unreadableRepos: recordedUnreadable } : {}),
// Same omit-when-unrecorded rule. This reader rebuilds the envelope field
// by field with no spread of `base`, so a new on-disk field is dropped
// unless it is named here.
...(recordedSuppressed ? { suppressedMatchStages: recordedSuppressed } : {}),
contracts,
crossLinks,
};
return { ok: true, registry, skippedCorrupt };
return { ok: true, registry, skippedCorrupt, suppressionUnreadable };
}
/**
* Validate a boolean MCP parameter — reject, never coerce.
*
* `Boolean(params.x)` is the trap this exists to close: the string `"false"`
* is truthy, and an LLM caller emitting JSON produces that shape routinely.
* While `exactOnly` was inert the coercion was harmless; now that it gates a
* matching stage, a coerced `"false"` suppresses that stage and persists a
* registry with fewer cross-links than the caller asked for.
*
* Absent stays absent-as-false (the unchanged default). Anything that is not
* a real boolean returns a structured `{ error }`, mirroring
* `validateImpactMode` — the established shape for this boundary, and the one
* `groupSync`'s other guards already use.
*/
function validateBooleanParam(name: string, raw: unknown): { value: boolean } | { error: string } {
if (raw === undefined) return { value: false };
if (typeof raw === 'boolean') return { value: raw };
return { error: `Invalid "${name}": expected true or false, got ${describeValue(raw)}.` };
}
/**
* Render an untrusted value for an error message, without throwing.
*
* `JSON.stringify` is the right shape here — it distinguishes the string
* `"false"` from the boolean, which is the whole point of the message — but it
* throws on a BigInt and on a cyclic object. A validator whose ERROR path can
* throw does not return the structured `{ error }` it promises: the caller gets
* a rejected promise instead of feedback it can act on, and `callTool` is
* reachable directly, so neither input is hypothetical.
*/
function describeValue(raw: unknown): string {
try {
const rendered = JSON.stringify(raw);
// `undefined`, a function, or a symbol serialize to `undefined`.
return rendered ?? String(raw);
} catch {
return typeof raw === 'bigint' ? `${raw}n` : Object.prototype.toString.call(raw);
}
}
/**
* Refuse parameters this tool used to accept and no longer does.
*
* The CLI rejects a removed flag outright because commander errors on an
* unknown option. The MCP path had no equivalent, so an agent working from a
* cached tool schema kept sending a retired key and was told nothing — the
* removal took away discoverability, not acceptance. Naming the parameter is
* what lets the caller correct itself on the next call.
*/
function rejectRetiredSyncParams(params: Record<string, unknown>): { error: string } | null {
for (const retired of ['skipEmbeddings', 'allowStale']) {
if (params[retired] !== undefined) {
return {
error: `"${retired}" was removed and is no longer accepted. Drop it from the call.`,
};
}
}
return null;
}
export class GroupService {
@ -384,6 +458,13 @@ export class GroupService {
async groupSync(params: Record<string, unknown>): Promise<unknown> {
const name = String(params.name ?? '').trim();
if (!name) return { error: 'name is required' };
// Before anything reads the group off disk: the MCP SDK does not enforce a
// tool's advertised `inputSchema` and `callTool` is reachable directly, so
// this method is the real validation boundary.
const exactOnly = validateBooleanParam('exactOnly', params.exactOnly);
if ('error' in exactOnly) return exactOnly;
const retired = rejectRetiredSyncParams(params);
if (retired) return retired;
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
let config: GroupConfig;
try {
@ -398,18 +479,23 @@ export class GroupService {
// group tools never need it — so deferring it here keeps that closure off
// MCP server startup entirely and off every non-sync group call. The CLI
// already does exactly this at `cli/group.ts`'s sync command.
const { syncGroup } = await import('./sync.js');
const { syncGroup, formatGroupSyncAmbiguousError } = await import('./sync.js');
const { GroupSyncLockError } = await import('./group-lock.js');
const { RegistryAmbiguousTargetError } = await import('../../storage/repo-manager.js');
let result: Awaited<ReturnType<typeof syncGroup>>;
try {
result = await syncGroup(config, {
groupDir,
exactOnly: Boolean(params.exactOnly),
skipEmbeddings: Boolean(params.skipEmbeddings),
allowStale: Boolean(params.allowStale),
verbose: Boolean(params.verbose),
exactOnly: exactOnly.value,
// `verbose` is deliberately NOT accepted here. It gates diagnostics on
// the server's logger, which an MCP caller cannot observe — advertising
// it would be exactly the kind of knob that does not do what the caller
// expects. `SyncOptions.verbose` stays for the CLI, which can see them.
});
} catch (err) {
if (err instanceof RegistryAmbiguousTargetError) {
return { error: formatGroupSyncAmbiguousError(err) };
}
// Fails closed (R9): this sync could not be protected against a concurrent
// one, so it did not run and wrote nothing. Return it through the same
// error channel a missing group uses — NEVER as a success payload of zeroes,
@ -423,6 +509,10 @@ export class GroupService {
unmatched: result.unmatched.length,
missingRepos: result.missingRepos,
unreadableRepos: result.unreadableRepos,
// The agent-facing half of the skipped-stage signal. A human sees it in
// the CLI summary; without this an agent would have to issue a second
// `group_contracts` call to discover its own sync was narrowed.
suppressedMatchStages: result.suppressedMatchStages,
// An agent that calls group_sync and then group_contracts a moment later
// can otherwise see contract counts that disagree with this payload, with
// nothing here explaining why the write was skipped.
@ -465,9 +555,14 @@ export class GroupService {
const { incompleteRepos: _incompleteRepos, ...truncation } = crossRepoCompleteness({
unreadableRepos,
missingRepos,
suppressedMatchStages: registry.suppressedMatchStages,
// An unrecorded `unreadableRepos` means this listing cannot say which
// repos the sync failed to read — so it cannot claim to be complete.
provenanceUnknown: unreadableRepos === undefined,
// Either kind of unreadable provenance forces the floor: a sync that
// could not say which repos it read, or a suppression record that was
// present and could not be parsed. Reading the second as "nothing was
// suppressed" would report an unparseable registry as complete.
provenanceUnknown: unreadableRepos === undefined || loaded.suppressionUnreadable,
// A contract LISTING declares no scope to intersect with: it is the whole
// registry, so every configured repo is in scope by construction. The
// `type`/`repo`/`unmatchedOnly` filters above narrow which rows are shown,
@ -482,6 +577,13 @@ export class GroupService {
// convention `skippedCorrupt` follows below, and the difference between
// "the sync measured zero unreadable repos" and "the sync never said".
...(unreadableRepos ? { unreadableRepos } : {}),
// Same omit-when-unrecorded rule, and deliberately NOT folded into the
// truncation triple below: that triple reports limits a run hit by
// accident, whose remedy is to fix the repo. A suppressed stage was
// asked for, and its remedy is to re-sync without that flag.
...(registry.suppressedMatchStages
? { suppressedMatchStages: registry.suppressedMatchStages }
: {}),
// The structured triple, verbatim from the impact surface (KTD10):
// `truncated` always, `truncationReason` + `riskEpistemic` with it.
...truncation,
@ -802,6 +904,10 @@ export class GroupService {
// "none" (see ContractRegistry), and a value we could not read is equally
// unrecorded. Reporting either as an empty list is the same conflation.
unreadableRepos: recordedRepoList(registry?.unreadableRepos),
// Same tri-state, same reason: `group status` is where an operator goes
// to ask "is this group's answer trustworthy right now", and a registry
// narrowed on purpose is a different answer from a complete one.
suppressedMatchStages: recordedMatchStages(registry?.suppressedMatchStages),
repos: repoStatuses,
};
}

View file

@ -96,15 +96,11 @@ packages: {}
detect:
http: true
graphql: false
grpc: true
topics: true
shared_libs: true
embedding_fallback: true
matching:
bm25_threshold: 0.7
embedding_threshold: 0.65
max_candidates_per_step: 3
# exclude_links_paths: [/ping, /health, /healthcheck]
# exclude_links_param_only_paths: false
`;

View file

@ -8,8 +8,12 @@ import {
getMaxResidentRepos,
} from '../lbug/pool-adapter.js';
import {
findRegistryEntryByName,
canonicalizePath,
registryPathEquals,
readRegistry,
readRegistryStrict,
RegistryAmbiguousTargetError,
type RegistryEntry,
} from '../../storage/repo-manager.js';
import type {
@ -19,8 +23,10 @@ import type {
StoredContract,
CrossLink,
GroupManifestLink,
MatchType,
} from './types.js';
import { HttpRouteExtractor } from './extractors/http-route-extractor.js';
import { GraphqlExtractor } from './extractors/graphql-extractor.js';
import { GrpcExtractor } from './extractors/grpc-extractor.js';
import { ThriftExtractor } from './extractors/thrift-extractor.js';
import { TopicExtractor } from './extractors/topic-extractor.js';
@ -28,10 +34,15 @@ import { IncludeExtractor } from './extractors/include-extractor.js';
import { ManifestExtractor } from './extractors/manifest-extractor.js';
import { discoverWorkspaceLinks } from './extractors/workspace-extractor.js';
import { buildProviderIndex, runExactMatch, runWildcardMatch } from './matching.js';
import type { WildcardMatchResult } from './matching.js';
import { detectServiceBoundaries, assignService } from './service-boundary-detector.js';
import type { CypherExecutor } from './contract-extractor.js';
import { getContractRegistryPath, readContractRegistry, writeContractRegistry } from './storage.js';
import { refreshPreservedBridgeMeta, writeBridgeUnlocked } from './bridge-db.js';
import {
markBridgeProvenanceUnknown,
refreshPreservedBridgeMeta,
writeBridgeUnlocked,
} from './bridge-db.js';
import { withGroupSyncLock } from './group-lock.js';
import type { ContractRegistry } from './types.js';
@ -43,10 +54,8 @@ export interface SyncOptions {
resolveRepoHandle?: (registryName: string, groupPath: string) => Promise<RepoHandle | null>;
skipWrite?: boolean;
groupDir?: string;
allowStale?: boolean;
verbose?: boolean;
exactOnly?: boolean;
skipEmbeddings?: boolean;
}
/**
@ -95,6 +104,14 @@ export interface SyncResult {
*/
unreadableRepos: string[];
repoSnapshots: Record<string, RepoSnapshot>;
/**
* Matching stages this run was asked to skip. Populated on EVERY outcome,
* not just `written`: the sync genuinely did skip the stage whatever
* happened to the file afterwards, and the CLI summary renders from this
* rather than re-deriving it from the caller's options. Only the persisted
* registry stamps it conditionally — see the write below.
*/
suppressedMatchStages: MatchType[];
/**
* What this sync did to `contracts.json`. Callers must not announce a write
* they did not get: without this, `group sync` printed "Wrote contracts.json
@ -115,9 +132,19 @@ export function stableRepoPoolId(entry: RegistryEntry, allEntries: RegistryEntry
return base;
}
/** Operator copy for group sync — unique `--name`, not a path in yaml. */
export function formatGroupSyncAmbiguousError(err: RegistryAmbiguousTargetError): string {
const listing = err.matches.map((m) => ` - ${m.path}`).join('\n');
return (
`Multiple registered repos are named "${err.target}":\n${listing}\n` +
`Give each clone a unique registry name with \`gitnexus analyze --name\`, then re-sync. ` +
`Do not put a filesystem path in group.yaml.`
);
}
function defaultResolveHandle(allEntries: RegistryEntry[]) {
return async (registryName: string, groupPath: string): Promise<RepoHandle | null> => {
const e = allEntries.find((en) => en.name === registryName);
const e = findRegistryEntryByName(allEntries, registryName);
if (!e) return null;
const poolId = stableRepoPoolId(e, allEntries);
return {
@ -264,7 +291,10 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
// Group-path → pool identity for repos that successfully initialized. Drives
// windowed manifest resolution below (re-init + lease per window). Keyed by
// group path because manifest links reference repos by group path.
const repoHandles = new Map<string, { poolId: string; lbugPath: string }>();
const repoHandles = new Map<string, { poolId: string; lbugPath: string; repoPath: string }>();
// Keep resolved disk paths even when extraction fails and removes the
// corresponding handle; workspace discovery does not need a readable index.
const resolvedRepoPaths = new Map<string, string>();
// Every eviction lease this sync holds. Window loops release their own leases
// (bounding residency); this set is the defensive outer-finally sweep —
// release disposers are idempotent, so double-release is a safe no-op.
@ -282,7 +312,13 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
registryEntries = await readRegistryStrict();
const entries = registryEntries;
const resolve = opts?.resolveRepoHandle ?? defaultResolveHandle(entries);
if (!opts?.resolveRepoHandle) {
for (const regName of Object.values(config.repos)) {
findRegistryEntryByName(entries, regName);
}
}
const httpEx = new HttpRouteExtractor();
const graphqlEx = new GraphqlExtractor();
const grpcEx = new GrpcExtractor();
const thriftEx = new ThriftExtractor();
const topicEx = new TopicExtractor();
@ -294,6 +330,7 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
missingRepos.push(groupPath);
continue;
}
resolvedRepoPaths.set(groupPath, handle.repoPath);
const poolId = handle.id;
const lbugPath = path.join(handle.storagePath, 'lbug');
@ -313,7 +350,7 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
// resolution no longer reuses these executors — it re-inits + leases
// each repo per window (see windowed resolution below, issue #2189).
// Record the pool identity so windowed resolution can re-init.
repoHandles.set(groupPath, { poolId, lbugPath });
repoHandles.set(groupPath, { poolId, lbugPath, repoPath: handle.repoPath });
const executor: CypherExecutor = (query, params) =>
executeParameterized(poolId, query, params ?? {});
@ -331,6 +368,17 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
}
}
if (config.detect.graphql === true) {
const extracted = await graphqlEx.extract(executor, handle.repoPath, handle);
for (const c of extracted) {
repoContracts.push({
...c,
repo: groupPath,
service: assignService(c.symbolRef.filePath, boundaries),
});
}
}
if (config.detect.grpc) {
const extracted = await grpcEx.extract(executor, handle.repoPath, handle);
for (const c of extracted) {
@ -384,7 +432,10 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
lastCommit: m.lastCommit || '',
};
} catch {
const e = entries.find((en) => en.name === regName);
const resolvedHandlePath = canonicalizePath(handle.repoPath);
const e = entries.find((en) =>
registryPathEquals(canonicalizePath(en.path), resolvedHandlePath),
);
repoSnapshots[groupPath] = {
indexedAt: e?.indexedAt || '',
lastCommit: e?.lastCommit || '',
@ -406,6 +457,7 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
// read. The loop bounds the append by memory instead.
for (const contract of repoContracts) autoContracts.push(contract);
} catch (err) {
if (err instanceof RegistryAmbiguousTargetError) throw err;
// This spans initLbug plus all contract extraction for the repo. The
// error used to be discarded entirely, so the only trace of (say) a
// storage-version mismatch was an empty contracts.json and a later
@ -440,7 +492,13 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
const repoPaths = new Map<string, string>();
if (!registryEntries) registryEntries = await readRegistry();
for (const [groupPath, regName] of Object.entries(config.repos)) {
const e = registryEntries.find((en) => en.name === regName);
const resolvedPath = resolvedRepoPaths.get(groupPath);
if (resolvedPath) {
repoPaths.set(groupPath, resolvedPath);
continue;
}
if (opts?.resolveRepoHandle) continue;
const e = findRegistryEntryByName(registryEntries, regName);
if (e) repoPaths.set(groupPath, e.path);
}
@ -561,7 +619,21 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
const providerIndex = buildProviderIndex(autoContracts, config.matching);
const { matched, unmatched } = runExactMatch(autoContracts, providerIndex, config.matching);
const wildcard = runWildcardMatch(unmatched, providerIndex);
// `exactOnly` had the same defect this PR removes `skipEmbeddings` for: it was
// declared, threaded through the CLI and MCP, and never read, so `--exact-only`
// silently produced wildcard links anyway. It is honoured here rather than
// deleted because — unlike the never-built BM25/embedding stages — the stage it
// names does exist, so the flag describes a real choice. Contracts left
// unmatched by the exact pass stay unmatched, which is exactly what it promises.
// `remaining`, not `unmatched`: this feeds SyncResult.unmatched below, so every
// contract the exact pass could not place has to be reported as unmatched
// rather than silently dropped from the count.
const wildcard: WildcardMatchResult = opts?.exactOnly
? { matched: [], remaining: unmatched }
: runWildcardMatch(unmatched, providerIndex);
// Measured, not assumed: `[]` says this run suppressed nothing, which is a
// different statement from a registry that never recorded the field at all.
const suppressedMatchStages: MatchType[] = opts?.exactOnly ? ['wildcard'] : [];
// Dedupe cross-links. Manifest contracts participate in runExactMatch, so a
// manifest-declared link can also emit a matchType:'exact' CrossLink with the
@ -583,6 +655,11 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
// not recorded, telling the operator to re-run the sync that had just
// succeeded. The tri-state only works if the writer commits to it.
unreadableRepos,
// Stamped only on the path that writes THIS run's contracts. The preserve
// path below re-writes `{ ...prior }`, so a carried-forward registry keeps
// the marker of the sync that actually produced its contracts instead of
// being relabelled with this run's request.
suppressedMatchStages,
contracts: allContracts,
crossLinks,
};
@ -765,6 +842,7 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
repoSnapshots,
missingRepos,
unreadableRepos,
suppressedMatchStages,
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
@ -777,11 +855,23 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
// true is the one thing not to do — it would recreate exactly the
// metadata/database mis-pairing the stamping on the preserve path above
// exists to prevent.
// Fail the stale bridge CLOSED. contracts.json has advanced and the
// database has not, so the two describe different syncs; leaving the
// bridge vouching for itself lets `group_impact` call a superseded
// graph complete while `group_contracts` reports the new one. This
// withdraws the claim without touching the database — the previous
// graph stays queryable, it just stops being called complete.
const withdrawn = await markBridgeProvenanceUnknown(groupDir);
const provenanceNote = withdrawn
? 'Its metadata has been marked provenance-unknown, so those answers now report as ' +
'a lower bound rather than as complete.'
: 'Its metadata could NOT be marked provenance-unknown, so those answers may still ' +
'report as complete despite describing an older sync.';
logger.warn(
{ err: msg, groupDir },
{ err: msg, groupDir, bridgeProvenanceWithdrawn: withdrawn },
'⚠️ writeBridge failed; contracts.json is intact and is the canonical copy, ' +
'but bridge.lbug was not replaced: cross-repo queries may still answer from ' +
"the previous sync's contracts, and nothing marks them as superseded. " +
`the previous sync's contracts. ${provenanceNote} ` +
'Re-run `gitnexus group sync` to retry.',
);
}
@ -792,6 +882,7 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
return {
contracts: allContracts,
crossLinks,
suppressedMatchStages,
unmatched: wildcard.remaining,
missingRepos,
unreadableRepos,

View file

@ -1,5 +1,16 @@
export type ContractType = 'http' | 'grpc' | 'thrift' | 'topic' | 'lib' | 'custom' | 'include';
export type MatchType = 'exact' | 'manifest' | 'wildcard' | 'bm25' | 'embedding';
import type { ImpactRisk, ImpactRiskResult } from 'gitnexus-shared';
export type ContractType =
| 'http'
| 'graphql'
| 'grpc'
| 'thrift'
| 'topic'
| 'lib'
| 'custom'
| 'include';
export type ManifestContractType = Exclude<ContractType, 'graphql'>;
export type MatchType = 'exact' | 'manifest' | 'wildcard';
export type ContractRole = 'provider' | 'consumer';
export interface GroupConfig {
@ -16,32 +27,29 @@ export interface GroupConfig {
export interface GroupManifestLink {
from: string;
to: string;
type: ContractType;
type: ManifestContractType;
contract: string;
role: ContractRole;
}
export interface DetectConfig {
http: boolean;
graphql?: boolean;
grpc: boolean;
thrift: boolean;
topics: boolean;
shared_libs: boolean;
embedding_fallback: boolean;
includes: boolean;
workspace_deps: boolean;
}
export interface MatchingConfig {
bm25_threshold: number;
embedding_threshold: number;
max_candidates_per_step: number;
/**
* HTTP paths to exclude from cross-link matching. Contracts at these paths
* HTTP paths or GraphQL root fields to exclude from cross-link matching. Contracts at these paths
* are still extracted and visible in the registry, but they don't produce
* cross-repo links. Useful for health-check endpoints (`/ping`, `/health`)
* that every service exposes and would otherwise create N×M false links.
* Trailing slashes are normalized before comparison.
* Trailing slashes are normalized before comparison. GraphQL fields may be
* written as `health` or `/health`.
* @default []
*/
exclude_links_paths?: string[];
@ -114,6 +122,20 @@ export interface ContractRegistry {
* absent means "not recorded", not "none".
*/
unreadableRepos?: string[];
/**
* Matching stages this sync was ASKED to skip, so a later reader can tell a
* short cross-link list from a complete one. `--exact-only` / `exactOnly`
* suppresses the wildcard stage, and the registry it writes is otherwise
* indistinguishable from one where that stage ran and matched nothing.
*
* Same tri-state as `unreadableRepos` and for the same reason: absent means
* "not recorded" (written before this field existed), `[]` means "measured,
* nothing was suppressed", and a populated list names the stages. Distinct
* from `truncated` / `truncationReason`, which report limits this run hit by
* accident — a suppressed stage is a deliberate request, and its remedy is
* "re-sync without exactOnly", not "fix the unreadable repo".
*/
suppressedMatchStages?: MatchType[];
contracts: StoredContract[];
crossLinks: CrossLink[];
}
@ -137,6 +159,13 @@ export interface RepoHandle {
* a retry. `'incomplete-sync'` is structural: the bridge itself was built from a
* sync that could not read every configured repo, so those repos' contracts are
* absent from every query against it until `gitnexus group sync` succeeds.
* `'suppressed-stage'` is structural too but has its own remedy: the sync was
* ASKED to skip a matching stage (`--exact-only`), so cross-links that stage
* would have found are absent by request. Retrying returns the same floor, and
* so does re-running the sync — the fix is to re-run it WITHOUT the flag. Kept a
* separate member rather than folded into `'incomplete-sync'` precisely because
* that remedy differs; telling an agent to repair a repo it read fine is the
* failure this distinction exists to prevent.
*
* A runtime array rather than a bare type union: every value here has to be
* explained on the agent-facing surface that returns it, and only an enumerable
@ -145,7 +174,12 @@ export interface RepoHandle {
* catch, so the list an agent is promised and the list the code can emit have
* to come from the same place.
*/
export const GROUP_IMPACT_TRUNCATION_REASONS = ['timeout', 'partial', 'incomplete-sync'] as const;
export const GROUP_IMPACT_TRUNCATION_REASONS = [
'timeout',
'partial',
'incomplete-sync',
'suppressed-stage',
] as const;
export type GroupImpactTruncationReason = (typeof GROUP_IMPACT_TRUNCATION_REASONS)[number];
@ -162,7 +196,17 @@ export interface GroupImpactResult {
modules_affected: number;
cross_repo_hits: number;
};
risk: string;
risk: ImpactRisk;
/**
* Two-axis (direct + total) risk from the local leg, then `mergeRisk` with
* crossings — compare File vs symbol here, not via top-level `risk`.
*/
riskSharedAxes?: ImpactRisk;
/**
* Local-leg scale metadata (File / skipped enrichment). Crossings do not
* invent process/module membership for File nodes.
*/
riskScale?: ImpactRiskResult['riskScale'];
/**
* `'lower-bound'` when the fan-out was cut short, so `risk` is a FLOOR, not a
* verdict. Same vocabulary as single-repo `impact`'s `epistemic` field.
@ -357,4 +401,12 @@ export interface BridgeMeta {
* Optional: a bridge written before this field existed does not record it.
*/
unreadableRepos?: string[];
/**
* Matching stages the sync that built this bridge was asked to skip.
* PERSISTED, like `unreadableRepos` and unlike `repoListsUnreadable` — a
* later `group_impact` or `trace` reads this bridge with no access to the run
* that produced it, and a narrowed graph is otherwise indistinguishable from
* a complete one. Same tri-state: absent is "not recorded".
*/
suppressedMatchStages?: MatchType[];
}

View file

@ -0,0 +1,83 @@
/**
* Incremental derived-layer writeback helpers (#3016).
*
* The derived layers — Leiden communities, execution flows, and the FTS
* indexes — are graph-wide, so every analyze run rebuilt all three in full no
* matter how small the diff. A surgical incremental write can instead:
* - drop and rebuild only the FTS indexes whose tables hold rows in the
* write set (LadybugDB still cannot DML a table with a live FTS index —
* #2589 — so a table being written must still lose its index first);
* - leave the untouched tables' rows alone, so their indexes stay live;
* - reuse persisted Community/Process rows only when the file-hash diff is
* empty (no added, changed, or deleted files). Any content change can
* add, rename, or retarget symbols that Leiden and flow extraction
* consume — a no-deletion edit is not a validity proof.
*/
import { FTS_INDEXES } from '../search/fts-schema.js';
import type { KnowledgeGraph } from '../graph/types.js';
import type { FileHashDiff } from '../../storage/file-hash.js';
const FTS_TABLE_NAMES: ReadonlySet<string> = new Set(FTS_INDEXES.map((i) => i.table));
/** The FTS-backed members of `tables`. */
export const ftsTablesAmong = (tables: Iterable<string>): Set<string> => {
const out = new Set<string>();
for (const table of tables) {
if (FTS_TABLE_NAMES.has(table)) out.add(table);
}
return out;
};
/**
* Whether a surgical incremental write may reuse the persisted derived layer.
*
* Deletions disqualify it: the persisted Community/Process rows and their
* MEMBER_OF / STEP_IN_PROCESS edges can reference nodes that no longer exist
* after this run, and nothing short of re-deriving can tell which.
*
* Added or content-changed files also disqualify it: they can introduce,
* rename, or retarget symbols and CALLS edges that Leiden and flow extraction
* consume. File-deletion-only was too weak a proof that the derived graph is
* still valid.
*/
export const shouldPreservePersistedDerivedGraph = (
diff: Pick<FileHashDiff, 'deleted' | 'added' | 'changed'>,
): boolean => diff.deleted.length === 0 && diff.added.length === 0 && diff.changed.length === 0;
/**
* FTS-backed node tables that the fresh graph will WRITE rows into for
* `fileSet` — the inserting half of the DML.
*
* Callers must union this with a DB probe for the deleting half
* (`nodeTablesWithRowsForFiles`): a table whose last row in these files was
* just removed by the edit has nothing here, but still holds a stale row that
* the writeback must delete, and deleting it means taking its index down too.
*/
export const incrementalFtsTablesFromGraph = (
graph: KnowledgeGraph,
fileSet: ReadonlySet<string>,
): Set<string> => {
const touched = new Set<string>();
graph.forEachNode((n) => {
const filePath = n.properties?.filePath as string | undefined;
if (!filePath || !fileSet.has(filePath)) return;
if (FTS_TABLE_NAMES.has(n.label)) touched.add(n.label);
});
return touched;
};
/**
* The node tables an incremental DETACH DELETE should target, given the FTS
* tables this run is rebuilding.
*
* Every non-FTS table (Folder, CodeElement, …) deletes as before. An FTS-backed
* table only deletes when its index is being rebuilt anyway, because deleting
* from it otherwise would mean DML against a live FTS index (#2589).
*/
export const nodeTablesForIncrementalDelete = (
allNodeTables: readonly string[],
rebuildingFtsTables: ReadonlySet<string>,
): string[] =>
allNodeTables.filter(
(tableName) => !FTS_TABLE_NAMES.has(tableName) || rebuildingFtsTables.has(tableName),
);

View file

@ -6,9 +6,9 @@
* replaced, produce a smaller KnowledgeGraph that contains:
*
* - Every node whose `properties.filePath` is in `toWriteSet`.
* - Every graph-wide node (Community, Process, and Spring metadata
* placeholders) — these are regenerated each run and must be fully
* rewritten.
* - Graph-wide Community/Process nodes unless `includeDerivedGraphWide`
* is false (#3016 incremental preserve). Spring metadata placeholders
* are always included.
* - Every relationship where AT LEAST ONE endpoint is in the writable
* set above. Relationships entirely between unchanged-file nodes
* are skipped — their rows are still in the DB and re-inserting
@ -122,13 +122,18 @@ const indexNodeFilePaths = (fullGraph: KnowledgeGraph): Map<string, string> => {
export const extractChangedSubgraph = (
fullGraph: KnowledgeGraph,
toWriteSet: ReadonlySet<string>,
options?: { includeDerivedGraphWide?: boolean },
): KnowledgeGraph => {
const sub = createKnowledgeGraph();
const writableNodeIds = new Set<string>();
const includeDerivedGraphWide = options?.includeDerivedGraphWide !== false;
fullGraph.forEachNode((n: GraphNode) => {
const filePath = n.properties?.filePath as string | undefined;
const include = (filePath && toWriteSet.has(filePath)) || isGraphWideNode(n);
const derivedWide =
includeDerivedGraphWide || (n.label !== 'Community' && n.label !== 'Process');
const include = (filePath && toWriteSet.has(filePath)) || (isGraphWideNode(n) && derivedWide);
if (include) {
sub.addNode(n);
writableNodeIds.add(n.id);

View file

@ -0,0 +1,170 @@
/**
* Does the index still reflect the files it actually covers?
*
* `status` used to answer this with a repo-wide `git status --porcelain`
* boolean, which says something different: whether the working tree differs
* from HEAD. Those two questions diverge in both directions. A scratch file,
* a build artifact, or a tracked file under a tool directory the indexer
* never reads makes the tree dirty while every indexed file is byte-current —
* and because `analyze` cannot commit or delete that file, the resulting
* "stale (re-run gitnexus analyze)" verdict was unclearable (#3077). It also
* misses the reverse case: reverting a file that was indexed while dirty
* leaves a clean tree over an index holding the pre-revert content.
*
* `meta.fileHashes` already records the exact set of files the last run
* covered, so the question can be answered directly. This module recomputes
* the coverage set with the same `walkRepositoryPaths` scan (ignore rules and
* dotfile handling stay shared) and the large-file cap recorded in
* `meta.indexCoverage`, hashes only the paths that can actually have changed
* since that run, and diffs against what was recorded.
*/
import { constants as fsConstants } from 'node:fs';
import { access } from 'node:fs/promises';
import path from 'node:path';
import { walkRepositoryPaths } from './ingestion/filesystem-walker.js';
import { computeFileHashesDetailed } from '../storage/file-hash.js';
import { listWorkingTreeDirtyPaths } from '../storage/git.js';
import { isGitNexusManagedPath } from '../storage/gitnexus-managed-paths.js';
import { chunk } from '../lib/utils.js';
import { logger } from './logger.js';
import type { RepoMeta } from '../storage/repo-meta.js';
/** Why the recorded coverage set could not be compared against disk at all. */
export type IndexContentUnmeasurableReason =
/** Metadata predates per-file hashes, or the run recorded none (non-git). */
| 'no-file-hashes'
/** The repository scan or hashing pass threw. */
| 'scan-failed';
/**
* A three-way verdict. `'unmeasurable'` is kept apart from `'current'` on
* purpose: it means the comparison never ran, which is not evidence the index
* is fresh. Legacy metadata without hashes still falls back to the working-tree
* check; a failed scan must not.
*/
export type IndexContentDrift =
| { kind: 'current'; coveredFileCount: number }
| { kind: 'drifted'; changed: string[]; added: string[]; deleted: string[] }
| { kind: 'unmeasurable'; reason: IndexContentUnmeasurableReason };
export type IndexCoveragePolicy = NonNullable<RepoMeta['indexCoverage']>;
const HASH_BATCH = 100;
const collectUnreadablePaths = async (
repoPath: string,
relPaths: readonly string[],
): Promise<string[]> => {
const unreadable: string[] = [];
for (const batch of chunk(relPaths, HASH_BATCH)) {
await Promise.all(
batch.map(async (rel) => {
try {
await access(path.join(repoPath, rel), fsConstants.R_OK);
} catch {
unreadable.push(rel);
}
}),
);
}
unreadable.sort();
return unreadable;
};
/**
* Compare the files recorded in `fileHashes` against the current working tree.
*
* `added` covers files the index would pick up but has never seen, so a new
* source file still reports stale — the index is genuinely incomplete then,
* and comparing only the recorded entries would wave that through.
*/
export const detectIndexContentDrift = async (
repoPath: string,
fileHashes: Readonly<Record<string, string>> | undefined,
coverage?: IndexCoveragePolicy,
): Promise<IndexContentDrift> => {
if (!fileHashes || Object.keys(fileHashes).length === 0) {
return { kind: 'unmeasurable', reason: 'no-file-hashes' };
}
// Excluded from BOTH sides, or GitNexus's own output guarantees a mismatch:
// analyze rewrites AGENTS.md/CLAUDE.md after recording hashes, so they read
// as `added` on a first run and `changed` on every run after that — a fresh
// index would report itself stale forever.
const recorded = Object.fromEntries(
Object.entries(fileHashes).filter(([rel]) => !isGitNexusManagedPath(rel)),
);
if (Object.keys(recorded).length === 0) {
return { kind: 'unmeasurable', reason: 'no-file-hashes' };
}
try {
const scanned = await walkRepositoryPaths(repoPath, undefined, {
quiet: true,
maxFileSizeBytes: coverage?.maxFileSizeBytes,
});
const scannedPaths = scanned.map((file) => file.path).filter((p) => !isGitNexusManagedPath(p));
const scannedSet = new Set(scannedPaths);
const recordedSet = new Set(Object.keys(recorded));
// Legacy indexes have `fileHashes` but no `indexCoverage`. A later default
// cap would omit a still-present hashed file and call it deleted. Recorded
// paths that still exist stay in the coverage set even if this walk skipped
// them for size.
const recovered = new Set<string>();
for (const rel of recordedSet) {
if (scannedSet.has(rel)) continue;
try {
await access(path.join(repoPath, rel), fsConstants.R_OK);
recovered.add(rel);
scannedSet.add(rel);
} catch {
// Missing or unreadable: stays deleted / changed below.
}
}
const added = scannedPaths.filter((p) => !recordedSet.has(p)).sort();
const deleted = [...recordedSet].filter((p) => !scannedSet.has(p)).sort();
const intersection = [...recordedSet].filter((p) => scannedSet.has(p));
const dirtyNow = listWorkingTreeDirtyPaths(repoPath);
const dirtyAtIndex = coverage?.dirtyPaths;
const dirtyNowSet = dirtyNow === null ? null : new Set(dirtyNow);
const dirtyAtIndexSet = dirtyAtIndex === undefined ? undefined : new Set(dirtyAtIndex);
const hashCandidates =
dirtyNowSet === null || dirtyAtIndexSet === undefined
? intersection
: intersection.filter(
(p) => dirtyAtIndexSet.has(p) || dirtyNowSet.has(p) || recovered.has(p),
);
const hashCandidateSet = new Set(hashCandidates);
const skipHash = intersection.filter((p) => !hashCandidateSet.has(p));
const unreadableFromAccess = await collectUnreadablePaths(repoPath, skipHash);
const unreadableSet = new Set(unreadableFromAccess);
const { hashes: hashed, unreadable: unreadableFromHash } = await computeFileHashesDetailed(
repoPath,
hashCandidates,
);
for (const p of unreadableFromHash) unreadableSet.add(p);
const changed: string[] = [];
for (const p of intersection) {
if (unreadableSet.has(p)) {
changed.push(p);
continue;
}
const currentHash = hashed.get(p) ?? recorded[p];
if (currentHash !== recorded[p]) changed.push(p);
}
changed.sort();
if (changed.length === 0 && added.length === 0 && deleted.length === 0) {
return { kind: 'current', coveredFileCount: scannedSet.size };
}
return { kind: 'drifted', changed, added, deleted };
} catch (err) {
logger.warn({ err, repoPath }, 'index content drift scan failed');
return { kind: 'unmeasurable', reason: 'scan-failed' };
}
};

View file

@ -1,11 +1,14 @@
import { checkpointKind } from './embedding-checkpoint.js';
import type { RepoMeta } from '../storage/repo-manager.js';
import { scopeExtractionFailureTotal } from './ingestion/scope-resolution/scope-extraction-failures.js';
export const INDEX_INCOMPLETE_REASONS = [
'incremental-in-progress',
'embedding-checkpoint-pending',
'embedding-count-unverified',
'graph-write-collapsed',
'scope-extraction-unverified',
'scope-extraction-failed',
] as const;
export type IndexIncompleteReason = (typeof INDEX_INCOMPLETE_REASONS)[number];
@ -130,7 +133,14 @@ export function detectGraphWriteCollapse(
/** Stable machine-readable reasons an index cannot be certified complete. */
export function getIndexIncompleteReasons(
meta:
| Pick<RepoMeta, 'incrementalInProgress' | 'embeddingCheckpoint' | 'graphWriteCollapsed'>
| Pick<
RepoMeta,
| 'incrementalInProgress'
| 'embeddingCheckpoint'
| 'graphWriteCollapsed'
| 'scopeExtractionFailures'
| 'scopeExtractionReceipt'
>
| null
| undefined,
): IndexIncompleteReason[] {
@ -142,6 +152,13 @@ export function getIndexIncompleteReasons(
// answers from a graph missing most of its edges, which is indistinguishable
// from a codebase that genuinely has no such relationships.
if (meta?.graphWriteCollapsed) reasons.push('graph-write-collapsed');
if (meta?.scopeExtractionReceipt !== 1) {
reasons.push('scope-extraction-unverified');
} else {
const total = scopeExtractionFailureTotal(meta.scopeExtractionFailures);
if (total === undefined) reasons.push('scope-extraction-unverified');
else if (total > 0) reasons.push('scope-extraction-failed');
}
if (meta?.embeddingCheckpoint) {
// The three checkpoint kinds are not one operator-facing state. GUARDRAILS
// and the runbook document `embedding-checkpoint-pending` as "N node(s)

View file

@ -57,16 +57,49 @@ const warnLargeFileSkip = (message: string): void => {
logger.warn(message);
};
export interface WalkRepositoryOptions {
/**
* Suppress the operator-facing large-file notice. Set by read-only callers
* such as `status`, which reuse this scan purely to learn which files the
* index covers and must not emit analyze's progress commentary.
*/
quiet?: boolean;
/**
* Override the large-file cap. `status` replays the bytes recorded at
* analyze time so `--max-file-size` / `GITNEXUS_MAX_FILE_SIZE` cannot
* silently drop a file that the index actually covers.
*/
maxFileSizeBytes?: number;
}
/**
* Phase 1: Scan repository — stat files to get paths + sizes, no content loaded.
* Memory: ~10MB for 100K files vs ~1GB+ with content.
*/
const assertWalkRootIsDirectory = async (repoPath: string): Promise<void> => {
let st;
try {
st = await fs.stat(repoPath);
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === 'ENOENT' || code === 'ENOTDIR') {
throw new Error(`walkRepositoryPaths: path does not exist: ${repoPath}`);
}
throw err;
}
if (!st.isDirectory()) {
throw new Error(`walkRepositoryPaths: not a directory: ${repoPath}`);
}
};
export const walkRepositoryPaths = async (
repoPath: string,
onProgress?: (current: number, total: number, filePath: string) => void,
options: WalkRepositoryOptions = {},
): Promise<ScannedFile[]> => {
await assertWalkRootIsDirectory(repoPath);
const ignoreFilter = await createIgnoreFilter(repoPath);
const maxFileSizeBytes = getMaxFileSizeBytes();
const maxFileSizeBytes = options.maxFileSizeBytes ?? getMaxFileSizeBytes();
const filtered = await glob('**/*', {
cwd: repoPath,
@ -117,7 +150,7 @@ export const walkRepositoryPaths = async (
left.path < right.path ? -1 : left.path > right.path ? 1 : 0,
);
if (skippedLarge > 0) {
if (skippedLarge > 0 && !options.quiet) {
const isDefault = maxFileSizeBytes === DEFAULT_MAX_FILE_SIZE_BYTES;
const isOverrideUnset = !process.env.GITNEXUS_MAX_FILE_SIZE;
const suffix = isDefault ? ', likely generated/vendored' : '';

View file

@ -126,11 +126,13 @@ import {
} from './javascript/index.js';
import { extractDispatchGuardRoutes } from '../route-extractors/dispatch-guard.js';
import { extractDataRouteTableRoutes } from '../route-extractors/data-route-table.js';
import { extractNestRoutes } from '../route-extractors/nest.js';
import { extractConvexEndpointProperties } from './typescript/convex-endpoint-metadata.js';
const extractJsTsRoutes = (...args: Parameters<typeof extractDispatchGuardRoutes>) => [
...extractDispatchGuardRoutes(...args),
...extractDataRouteTableRoutes(...args),
...extractNestRoutes(...args),
];
/**

View file

@ -56,6 +56,8 @@ export interface WorkerExtractedData {
* finalize-orchestrator.
*/
parsedFiles: ParsedFile[];
/** Scope-extraction omissions represented by this worker/cache result. */
scopeExtractionFailures: string[];
}
type ParsedGraphNode = ParseWorkerResult['nodes'][number];
@ -126,6 +128,7 @@ export const mergeChunkResults = (
const allORMQueries: ExtractedORMQuery[] = [];
const fileScopeBindingsByFile: FileScopeBindings[] = [];
const allParsedFiles: ParsedFile[] = [];
const scopeExtractionFailures: string[] = [];
for (const result of chunkResults) {
// Worker jobs and input files are already merged in stable start-index/path
@ -178,6 +181,9 @@ export const mergeChunkResults = (
if (result.fileScopeBindings)
for (const item of result.fileScopeBindings) fileScopeBindingsByFile.push(item);
if (result.parsedFiles) for (const item of result.parsedFiles) allParsedFiles.push(item);
for (const filePath of result.scopeExtractionFailures ?? []) {
scopeExtractionFailures.push(filePath);
}
}
return {
@ -195,6 +201,7 @@ export const mergeChunkResults = (
springTypes: allSpringTypes,
fileScopeBindings: fileScopeBindingsByFile,
parsedFiles: allParsedFiles,
scopeExtractionFailures,
};
};

View file

@ -2,7 +2,7 @@
* Parse implementation — chunked parse + resolve loop.
*
* This is the core parsing engine of the ingestion pipeline. It reads
* source files in byte-budget chunks (~20MB each), parses via the worker
* source files in stable hash-bucket packs (~2MB each by default), parses via the worker
* pool (the sole parse path — there is no sequential fallback), and emits
* route CALLS edges. Import,
* call, and inheritance resolution are owned by the scope-resolution
@ -27,6 +27,7 @@ import {
loadParseCacheChunk,
persistParseCacheChunk,
PARSE_CACHE_VERSION,
packParseCacheChunks,
} from '../../../storage/parse-cache.js';
import {
clearParsedFileStore,
@ -35,7 +36,7 @@ import {
getDurableParsedFileDir,
loadDurableParsedFileIndex,
prepareDurableParsedFileChunk,
restoreDurableParsedFileShard,
durableChunkHasShards,
} from '../../../storage/parsedfile-store.js';
import type { ParseWorkerResult } from '../workers/parse-worker.js';
import { DEFAULT_PDG_MAX_FUNCTION_LINES } from '../cfg/collect.js';
@ -192,39 +193,19 @@ export function heapPressureRemedy(heapLimitBytes: number): string {
);
}
/** Max bytes of source content to load per parse chunk.
/** Max bytes of source content to load per parse cache pack.
*
* Memory bound for the worker pool dispatch + a granularity knob for
* the parse cache. A single file change invalidates only its enclosing
* chunk, so smaller budgets → finer-grained invalidation.
*
* Override via GITNEXUS_CHUNK_BYTE_BUDGET (bytes) — the default of 2MB
* gives a useful invalidation floor (~1/N chunks on a multi-MB repo)
* while keeping worker dispatch overhead under 5% on cold runs.
*/
/**
* Built-in chunk byte budget when neither `PipelineOptions.chunkByteBudget`
* nor `GITNEXUS_CHUNK_BYTE_BUDGET` is set. Tuned to give a useful
* cache-invalidation floor (~1/N chunks on a multi-MB repo) while keeping
* worker dispatch overhead under 5% on cold runs. Resolution happens at
* call time inside `runChunkedParseAndResolve` (U14 from PR #1693 review)
* — previously this was a module-load IIFE, which froze the env value at
* import time and meant per-call option threading silently no-op'd.
* Granularity knob for the parse cache: a single file change invalidates only
* its enclosing pack. Override via GITNEXUS_CHUNK_BYTE_BUDGET. Resolution
* happens at call time (U14 from PR #1693) — not at module load.
*/
const DEFAULT_CHUNK_BYTE_BUDGET = 2 * 1024 * 1024;
/**
* Per-worker share of a chunk's byte budget when auto-scaling (#worker-idle).
*
* A chunk is a single `WorkerPool.dispatch` unit; the pool fans a chunk's files
* into sub-batch jobs and assigns them to idle workers (`wakeIdleSlots`). When
* the chunk budget (2 MB) was far below the 8 MB sub-batch cap, every chunk
* produced exactly ONE job → ONE busy worker while the other N-1 sat idle. To
* keep all workers fed, the auto chunk budget now scales as
* `poolSize × CHUNK_BYTES_PER_WORKER`, so each dispatch carries enough work to
* fan across the whole pool. Sequential / explicit-budget runs are unaffected.
* Byte unit for auto pool sizing (one worker per this much source). Same
* magnitude as the default cache pack, but not a membership input (#3088).
*/
const CHUNK_BYTES_PER_WORKER = 2 * 1024 * 1024;
const CHUNK_BYTES_PER_WORKER = DEFAULT_CHUNK_BYTE_BUDGET;
/**
* Target jobs-per-worker per dispatch. More jobs than workers gives the pool's
@ -236,14 +217,12 @@ const TARGET_JOBS_PER_WORKER = 3;
/** Floor for a derived sub-batch so jobs don't shrink to per-file IPC churn. */
const MIN_SUB_BATCH_BYTES = 256 * 1024;
function resolveChunkByteBudget(options?: PipelineOptions, effectivePoolSize = 1): number {
function resolveChunkByteBudget(options?: PipelineOptions): number {
const opt = options?.chunkByteBudget;
if (typeof opt === 'number' && Number.isFinite(opt) && opt > 0) return opt;
const env = Number(process.env.GITNEXUS_CHUNK_BYTE_BUDGET);
if (Number.isFinite(env) && env > 0) return env;
// Auto: size each chunk so a dispatch can fan across the whole pool. A
// single-worker (tiny-repo) run keeps the original 2 MB invalidation floor.
return Math.max(DEFAULT_CHUNK_BYTE_BUDGET, effectivePoolSize * CHUNK_BYTES_PER_WORKER);
return DEFAULT_CHUNK_BYTE_BUDGET;
}
// ── Main parse + resolve function ──────────────────────────────────────────
@ -477,11 +456,16 @@ export async function runChunkedParseAndResolve(
* files. There is no sequential parser — the pool is the sole parse path
* whenever a chunk misses the cache. */
usedWorkerPool: boolean;
/** Files dispatched to parser workers after parse-cache lookup. */
reparsedFileCount: number;
/** Worker-produced ParsedFile artifacts aggregated across chunks.
* Threaded into scope-resolution as a re-extract cache so the warm-
* cache analyze run can skip the dominant `extractParsedFile` cost
* (otherwise ~58s on a 1000-file repo). */
parsedFiles: import('gitnexus-shared').ParsedFile[];
scopeExtractionFailures: string[];
/** Files excluded because their non-standalone language parser was unavailable. */
unavailableScopeLanguageFiles: number;
}> {
const model = createSemanticModel();
const symbolTable = model.symbols;
@ -514,15 +498,10 @@ export async function runChunkedParseAndResolve(
);
}
}
// Sort parseableScanned alphabetically for stable chunk membership
// across runs (Finding 4). Without this, filesystem-scan order can
// shift between runs (notably on macOS APFS where directory entry
// order can change after modifications) — different files in the
// same chunk → different chunk hash → cache miss even when no file
// content changed. The cache also becomes platform-specific: a
// Linux-built cache misses on macOS for the same repo.
parseableScanned.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
const unavailableScopeLanguageFiles = [...skippedByLang.values()].reduce(
(total, count) => total + count,
0,
);
const totalParseable = parseableScanned.length;
const totalBytes = parseableScanned.reduce((sum, f) => sum + f.size, 0);
@ -570,25 +549,25 @@ export async function runChunkedParseAndResolve(
// runs. Resolving in the function body restores per-call configurability
// and matches the pattern used by resolveAutoPoolSize and the U1
// parseChunkConcurrency resolver.
// Effective worker count, computed up-front so the chunk budget can scale to
// keep the whole pool busy (#worker-idle). The pool is ALWAYS used (sequential
// parsing was removed; the disabled channels threw above). Size it to the
// work: an explicit `--workers <N>` pins the size; otherwise the cores-based
// auto size is capped by the repo's worth of work (~one worker per
// CHUNK_BYTES_PER_WORKER of source) so a tiny repo spawns ~1 worker instead of
// a full pool, replacing the job the deleted small-repo threshold used to do.
// KTD-3 of the remove-sequential plan; the cap formula is intentionally coarse
// (tuning deferred).
// Effective worker count: explicit `--workers <N>` pins it; otherwise
// cores-based auto size is capped by source bytes / CHUNK_BYTES_PER_WORKER
// so a tiny repo does not spawn a full idle pool. Cache pack membership
// is independent of this number (#3088).
const explicitPoolSize = options?.workerPoolSize;
const workProportionalCap = Math.max(1, Math.ceil(totalBytes / CHUNK_BYTES_PER_WORKER));
const effectivePoolSize =
explicitPoolSize && explicitPoolSize > 0
? explicitPoolSize
: Math.min(resolveAutoPoolSize(), workProportionalCap);
const chunkByteBudget = resolveChunkByteBudget(options, effectivePoolSize);
// Sub-batch size so each chunk fans into ~`TARGET_JOBS_PER_WORKER` jobs per
// worker, giving the pool's idle-slot assignment room to load-balance. An
// explicit `GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES` operator override wins.
// Cache packs: stable (language, hash(path) mod 128) buckets, then the
// per-call byte budget inside each bucket (#3088). Pool size is used only
// for worker count and sub-batch fan-out, not membership.
const chunkByteBudget = resolveChunkByteBudget(options);
// Sub-batch size so a 2 MiB pack fans into ~TARGET_JOBS_PER_WORKER jobs
// per worker, floored at MIN_SUB_BATCH_BYTES (256 KiB) so an 8-worker
// pool still gets ~8 jobs from one pack instead of one idle-heavy job
// (#worker-idle). Do not derive this from pool×2 MiB while dispatching a
// 2 MiB pack. An explicit GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES wins.
const subBatchEnv = Number(process.env.GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES);
const dispatchSubBatchMaxBytes =
Number.isFinite(subBatchEnv) && subBatchEnv > 0
@ -613,19 +592,14 @@ export async function runChunkedParseAndResolve(
);
}
const chunks: string[][] = [];
let currentChunk: string[] = [];
let currentBytes = 0;
for (const file of parseableScanned) {
if (currentChunk.length > 0 && currentBytes + file.size > chunkByteBudget) {
chunks.push(currentChunk);
currentChunk = [];
currentBytes = 0;
}
currentChunk.push(file.path);
currentBytes += file.size;
}
if (currentChunk.length > 0) chunks.push(currentChunk);
const chunks: string[][] = packParseCacheChunks(
parseableScanned.map((file) => ({
path: file.path,
size: file.size,
language: getLanguageFromFilename(file.path) ?? 'unknown',
})),
chunkByteBudget,
);
const numChunks = chunks.length;
@ -743,6 +717,7 @@ export async function runChunkedParseAndResolve(
// the second-half of the parse-cache speedup since scope-resolution's
// re-parse otherwise dominates the warm-cache wall-clock time.
const allParsedFiles: import('gitnexus-shared').ParsedFile[] = [];
const scopeExtractionFailures = new Set<string>();
// Incremental parse cache (Option B): chunk-level content-addressed.
// When the chunk's (filePath, content-hash) signature matches a prior
@ -764,17 +739,18 @@ export async function runChunkedParseAndResolve(
// a sibling of the run-scoped store, NOT cleared per run. Workers write a
// shard per chunk hash; on a warm parse-cache hit we restore the chunk's
// shards into the run-scoped store so scope-resolution streams them without
// re-parsing. `durableHitKeys` is the prior run's index, version-gated by
// PARSE_CACHE_VERSION (a mismatch ⇒ empty ⇒ every chunk re-dispatches, which
// repopulates the durable store — never the main-thread extract fallback).
// re-parsing. `durableHitEntries` is the prior run's path-coverage index,
// version-gated by PARSE_CACHE_VERSION (a mismatch ⇒ empty ⇒ every chunk
// re-dispatches, which repopulates the durable store).
const durableParsedFileDir =
parsedFileStorePath !== undefined ? getDurableParsedFileDir(parsedFileStorePath) : undefined;
const durableHitKeys =
const durableHitEntries =
durableParsedFileDir !== undefined
? await loadDurableParsedFileIndex(durableParsedFileDir, PARSE_CACHE_VERSION)
: new Set<string>();
: new Map<string, ReadonlySet<string>>();
let chunkCacheHits = 0;
let chunkCacheMisses = 0;
let reparsedFileCount = 0;
try {
// U1 — bounded chunk concurrency (B1 from PR #1693 review): pre-fetch
@ -844,13 +820,19 @@ export async function runChunkedParseAndResolve(
chunkStartMs: number | null,
): Promise<void> => {
if (chunkWorkerData) {
for (const filePath of chunkWorkerData.scopeExtractionFailures) {
scopeExtractionFailures.add(filePath);
}
if (chunkWorkerData.parsedFiles?.length) {
if (parsedFileStorePath) {
await persistParsedFileChunk(
const wrote = await persistParsedFileChunk(
parsedFileStorePath,
`chunk-${chunkIdx}`,
chunkWorkerData.parsedFiles,
);
if (!wrote) {
for (const item of chunkWorkerData.parsedFiles) allParsedFiles.push(item);
}
} else {
for (const item of chunkWorkerData.parsedFiles) allParsedFiles.push(item);
}
@ -1040,8 +1022,16 @@ export async function runChunkedParseAndResolve(
// store was introduced, or a pruned/version-stale shard — fall through to
// a worker re-dispatch to repopulate them. NEVER let scope-resolution
// re-extract on the main thread (the #1983 OOM the durable store closes).
const durableExpectedPaths =
chunkHash === null ? undefined : durableHitEntries.get(chunkHash);
const durableHit =
chunkHash !== null && durableParsedFileDir !== undefined && durableHitKeys.has(chunkHash);
cachedRaw !== undefined &&
cachedRaw.length > 0 &&
chunkHash !== null &&
durableParsedFileDir !== undefined &&
parsedFileStorePath !== undefined &&
durableExpectedPaths !== undefined &&
(await durableChunkHasShards(parsedFileStorePath, chunkHash, durableExpectedPaths));
if (cachedRaw && cachedRaw.length > 0 && (durableHit || parsedFileStorePath === undefined)) {
// Cache hit: replay cached worker output. Finalize any parked worker
@ -1074,27 +1064,14 @@ export async function runChunkedParseAndResolve(
nodesCreated: graph.nodeCount,
},
});
// Restore the chunk's durable ParsedFile shards into the run-scoped
// store so scope-resolution finds full coverage with ZERO main-thread
// re-parse. A verbatim byte copy — byte-identical to a cold run.
if (durableHit && durableParsedFileDir && parsedFileStorePath && chunkHash) {
const restored = await restoreDurableParsedFileShard(
durableParsedFileDir,
parsedFileStorePath,
chunkHash,
);
if (restored === 0) {
logger.warn(
`parsedfile-cache: durable shards missing for cached chunk ` +
`${chunkHash.slice(0, 8)} — scope-resolution will re-extract these files`,
);
}
}
// The durable gate already snapshotted warm `.v8` shards into the
// run-scoped store for scope resolution.
await applyChunkResults(chunkWorkerData, chunkIdx, chunkFiles, chunkStartMs);
} else {
// Cache miss: dispatch to workers, capture the raw results, store
// them under the chunk hash for the next run.
chunkCacheMisses++;
reparsedFileCount += chunkFiles.length;
if (durableParsedFileDir !== undefined && chunkHash !== null) {
try {
await prepareDurableParsedFileChunk(durableParsedFileDir, chunkHash);
@ -1611,10 +1588,17 @@ export async function runChunkedParseAndResolve(
// no pool was needed: a warm all-cache-hit run replays cached worker output
// without spawning workers, or there were no parseable files.
usedWorkerPool: workerPool !== undefined,
// Exact number of files sent through workers on parse-cache misses. A
// changed file can invalidate its whole content-addressed chunk, so this
// is intentionally measured at dispatch time rather than inferred from
// the git/hash diff.
reparsedFileCount,
// Per-file ParsedFile artifacts produced by workers' calls to
// `extractParsedFile`. Consumed by scope-resolution as a re-extraction
// cache: when the file's ParsedFile is here, scope-resolution skips its own
// `extractParsedFile` call.
parsedFiles: allParsedFiles,
scopeExtractionFailures: [...scopeExtractionFailures].sort(),
unavailableScopeLanguageFiles,
};
}

View file

@ -71,6 +71,8 @@ export interface ParseOutput {
* is no sequential parser; the pool is the sole parse path on a cache miss.
*/
readonly usedWorkerPool: boolean;
/** Files actually dispatched to parser workers after parse-cache lookup. */
readonly reparsedFileCount: number;
/**
* Per-file `ParsedFile` artifacts produced by workers' calls to
* `extractParsedFile`. Threaded through to `scopeResolutionPhase`
@ -80,6 +82,10 @@ export interface ParseOutput {
* costing ~58s on a 1000-file repo).
*/
readonly parsedFiles: readonly ParsedFile[];
/** Files whose scope extraction failed while legacy parsing continued. */
readonly scopeExtractionFailures: readonly string[];
/** Files omitted because their non-standalone language parser was unavailable. */
readonly unavailableScopeLanguageFiles: number;
}
export const parsePhase: PipelinePhase<ParseOutput> = {

View file

@ -195,6 +195,45 @@ export const routesPhase: PipelinePhase<RoutesOutput> = {
const allFetchCalls = [...parseFetchCalls];
const routeRegistry = new Map<string, RouteEntry>();
/**
* Registry keys written straight from the file list below, never through
* `addRoute`. `resolveRouteHandlerSymbols` walks only `extractedRoutes` and
* `decoratorRoutes`, so it never sees these URLs and its `claimed` set never
* contains them — which means a handler stamped on one of these keys was
* resolved for a DIFFERENT route.
*
* That is reachable, and it fabricates rather than omits (#3049). A
* method-agnostic route (`@All`, a Django function view, a verb-less
* dispatch guard) keys by URL alone via `routeNodeKey`, so it collides with
* a file-convention route at the same URL. It claims the key unopposed in
* `claim()`, then loses first-writer-wins here in `addRoute` and is dropped
* as a duplicate — and without this guard the surviving file-convention node
* would read that handler and present another application's controller
* method as its own. `api_impact` is documented to be run BEFORE editing a
* route handler, so it would answer with a handler from the wrong app.
*
* Dropping the losing route is a separate and deliberate consequence of
* URL-only identity; this only stops the false attribution.
*
* Membership is recorded AT the pre-seeding `set`, mirroring `claim()` in
* call-processor.ts, which writes `claimed` and its result map together
* rather than re-deriving either by rescanning. Identifying pre-seeded
* entries by matching `entry.source` against a list of source strings would
* spell them a second time, away from the sites that produce them — and a
* fourth pre-seeded source added later would then reopen #3049 in silence.
* `addRoute` deliberately does NOT record here: its routes ARE
* handler-resolved, so suppressing them would widen the guard into a bug of
* its own.
*
* Each `add` below sits inside its own `!routeRegistry.has(key)` gate, as
* every `routeRegistry.set` in this phase does: the map is write-once per
* key, so a losing candidate cannot record a key it did not claim and no
* later writer can take a recorded key away. Key-membership is therefore
* equivalent to source-matching by construction — pre-seeded routes carry no
* verb and `routeNodeKey(undefined, url) === url` — which is why the
* two-candidates-one-URL case needs no fixture to settle it.
*/
const preSeededKeys = new Set<string>();
// Detect Expo Router app/ roots vs Next.js app/ roots (monorepo-safe)
const expoAppRoots = new Set<string>();
@ -217,32 +256,33 @@ export const routesPhase: PipelinePhase<RoutesOutput> = {
}
}
// One writer for every pre-seeded route, so recording membership cannot be
// forgotten. Inlining `has` / `set` / `add` at each site made the invariant
// a convention three call sites had to remember — and a fourth source that
// forgot the `add` would reopen #3049 exactly as silently as the source-set
// it replaced. This is the shape `claim()` in call-processor.ts uses for the
// same reason: one helper writes the collection and its key set together.
const preSeed = (url: string, entry: Omit<RouteEntry, 'url'>): boolean => {
if (routeRegistry.has(url)) return false;
routeRegistry.set(url, { ...entry, url });
preSeededKeys.add(url);
return true;
};
for (const p of allPaths) {
if (expoAppPaths.has(p)) {
const expoURL = expoFileToRouteURL(p);
if (expoURL && !routeRegistry.has(expoURL)) {
routeRegistry.set(expoURL, {
filePath: p,
source: 'expo-filesystem-route',
url: expoURL,
});
if (expoURL && preSeed(expoURL, { filePath: p, source: 'expo-filesystem-route' })) {
continue;
}
}
const nextjsURL = nextjsFileToRouteURL(p);
if (nextjsURL && !routeRegistry.has(nextjsURL)) {
routeRegistry.set(nextjsURL, {
filePath: p,
source: 'nextjs-filesystem-route',
url: nextjsURL,
});
if (nextjsURL && preSeed(nextjsURL, { filePath: p, source: 'nextjs-filesystem-route' })) {
continue;
}
if (p.endsWith('.php')) {
const phpURL = phpFileToRouteURL(p);
if (phpURL && !routeRegistry.has(phpURL)) {
routeRegistry.set(phpURL, { filePath: p, source: 'php-file-route', url: phpURL });
}
if (phpURL) preSeed(phpURL, { filePath: p, source: 'php-file-route' });
}
}
@ -311,7 +351,11 @@ export const routesPhase: PipelinePhase<RoutesOutput> = {
const { source: routeSource, method: routeMethod, url } = entry;
const handlerPath = handlerPathFor(routeKey, entry);
const content = handlerContents.get(handlerPath);
const handlerSymbolId = routeHandlerSymbols.get(routeKey);
// A pre-seeded route can never legitimately appear in
// `routeHandlerSymbols`, so a key that does is a route that LOST (#3049).
const handlerSymbolId = preSeededKeys.has(routeKey)
? undefined
: routeHandlerSymbols.get(routeKey);
const analysisContent =
entry.source === DATA_ROUTE_TABLE_SOURCE && content
? handlerSymbolContent(

View file

@ -16,23 +16,36 @@ import type { PipelinePhase, PipelineContext, PhaseResult } from './types.js';
import { isDev } from '../utils/env.js';
import { logger } from '../../logger.js';
function assertUniquePhaseNames(phases: readonly PipelinePhase[]): void {
const seen = new Set<string>();
for (const phase of phases) {
if (seen.has(phase.name)) {
throw new Error(`Duplicate phase name: '${phase.name}'`);
}
seen.add(phase.name);
}
}
/**
* Validate that the phases form a valid dependency graph (no cycles, all deps present).
* Returns phases in topological execution order.
*
* `satisfied` names phases whose results are already available (a deferred
* follow-up run over the same context, #3016). Their edges are dropped rather
* than validated, because they are resolved by definition.
*/
function topologicalSort(phases: readonly PipelinePhase[]): PipelinePhase[] {
const phaseMap = new Map<string, PipelinePhase>();
for (const phase of phases) {
if (phaseMap.has(phase.name)) {
throw new Error(`Duplicate phase name: '${phase.name}'`);
}
phaseMap.set(phase.name, phase);
}
function topologicalSort(
phases: readonly PipelinePhase[],
satisfied: ReadonlySet<string> = new Set(),
): PipelinePhase[] {
assertUniquePhaseNames(phases);
const phaseMap = new Map<string, PipelinePhase>(phases.map((p) => [p.name, p]));
// Validate all deps exist
for (const phase of phases) {
for (const dep of phase.deps) {
if (!phaseMap.has(dep)) {
if (!phaseMap.has(dep) && !satisfied.has(dep)) {
throw new Error(`Phase '${phase.name}' depends on '${dep}', which is not registered`);
}
}
@ -43,8 +56,9 @@ function topologicalSort(phases: readonly PipelinePhase[]): PipelinePhase[] {
const reverseDeps = new Map<string, string[]>();
for (const phase of phases) {
inDegree.set(phase.name, phase.deps.length);
for (const dep of phase.deps) {
const pendingDeps = phase.deps.filter((dep) => !satisfied.has(dep));
inDegree.set(phase.name, pendingDeps.length);
for (const dep of pendingDeps) {
let rev = reverseDeps.get(dep);
if (!rev) {
rev = [];
@ -143,15 +157,30 @@ function findCyclePath(
*
* @param phases All phases to execute (order doesn't matter — sorted internally)
* @param ctx Shared pipeline context
* @param seed Results of phases that already ran against this same context,
* available to `phases` as dependencies (#3016 deferred derived
* phases). Included in the returned map.
* @returns Map of phase name → PhaseResult (all completed phases)
*/
export async function runPipeline(
phases: readonly PipelinePhase[],
ctx: PipelineContext,
seed?: ReadonlyMap<string, PhaseResult<unknown>>,
): Promise<ReadonlyMap<string, PhaseResult<unknown>>> {
// A seeded phase has already run against this context; re-running it would
// apply its graph writes a second time. "Already ran" is the whole meaning of
// the seed, so honour it here rather than making every caller pre-filter.
const satisfied = new Set(seed?.keys() ?? []);
let sorted: PipelinePhase[];
try {
sorted = topologicalSort(phases);
// Duplicate names must be rejected on the caller-supplied list *before*
// seed-filtering. Filtering first would drop a seeded duplicate and let
// `topologicalSort` see a unique name (#3102).
assertUniquePhaseNames(phases);
sorted = topologicalSort(
phases.filter((p) => !satisfied.has(p.name)),
satisfied,
);
} catch (err) {
// Emit a terminal 'error' progress event for graph-validation failures
// (cycle detected, duplicate phase, missing dep) so CLI/MCP consumers see
@ -171,7 +200,7 @@ export async function runPipeline(
}
throw err;
}
const results = new Map<string, PhaseResult<unknown>>();
const results = new Map<string, PhaseResult<unknown>>(seed);
for (const phase of sorted) {
const start = Date.now();

View file

@ -30,20 +30,31 @@ export const scanPhase: PipelinePhase<ScanOutput> = {
message: 'Scanning repository...',
});
const scannedFiles = await walkRepositoryPaths(ctx.repoPath, (current, total, filePath) => {
const scanProgress = Math.round((current / total) * 15);
ctx.onProgress({
phase: 'extracting',
percent: scanProgress,
message: 'Scanning repository...',
detail: filePath,
stats: {
filesProcessed: current,
totalFiles: total,
nodesCreated: ctx.graph.nodeCount,
},
let scannedFiles;
try {
scannedFiles = await walkRepositoryPaths(ctx.repoPath, (current, total, filePath) => {
const scanProgress = Math.round((current / total) * 15);
ctx.onProgress({
phase: 'extracting',
percent: scanProgress,
message: 'Scanning repository...',
detail: filePath,
stats: {
filesProcessed: current,
totalFiles: total,
nodesCreated: ctx.graph.nodeCount,
},
});
});
});
} catch (err) {
// Missing roots throw so status cannot treat an empty glob as "every
// covered file was deleted". The pipeline still reports an empty scan
// for a path that is not a directory, matching analyze of a bad cwd.
if (err instanceof Error && err.message.startsWith('walkRepositoryPaths:')) {
return { scannedFiles: [], allPaths: [], totalFiles: 0 };
}
throw err;
}
const totalFiles = scannedFiles.length;
const allPaths = scannedFiles.map((f) => f.path);

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