mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-11-14 09:01:33 +00:00
Merge branch 'main' into fix/dart-test-path-detection
This commit is contained in:
commit
bc3b87dd7c
244 changed files with 23580 additions and 1695 deletions
|
|
@ -6,7 +6,7 @@
|
|||
"plugins": [
|
||||
{
|
||||
"name": "gitnexus",
|
||||
"version": "1.6.9",
|
||||
"version": "1.6.10",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./gitnexus-claude-plugin"
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
|
|
@ -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 }}'
|
||||
|
|
|
|||
2
.github/workflows/docker.yml
vendored
2
.github/workflows/docker.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/scorecard.yml
vendored
2
.github/workflows/scorecard.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
4
.github/workflows/trivy.yml
vendored
4
.github/workflows/trivy.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
2
.github/workflows/workflow-lint.yml
vendored
2
.github/workflows/workflow-lint.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
31
README.md
31
README.md
|
|
@ -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
|
||||
|
|
|
|||
11
RUNBOOK.md
11
RUNBOOK.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
312
docs/plans/2026-08-28-gitnexus-plan-impact-file-risk.md
Normal file
312
docs/plans/2026-08-28-gitnexus-plan-impact-file-risk.md
Normal 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.
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@1.6.9", "mcp"]
|
||||
"args": ["-y", "gitnexus@1.6.10", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@1.6.9", "mcp"]
|
||||
"args": ["-y", "gitnexus@1.6.10", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@1.6.9", "mcp"]
|
||||
"args": ["-y", "gitnexus@1.6.10", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@1.6.9", "mcp"]
|
||||
"args": ["-y", "gitnexus@1.6.10", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@1.6.9", "mcp"]
|
||||
"args": ["-y", "gitnexus@1.6.10", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@1.6.9", "mcp"]
|
||||
"args": ["-y", "gitnexus@1.6.10", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@1.6.9", "mcp"]
|
||||
"args": ["-y", "gitnexus@1.6.10", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@1.6.9", "mcp"]
|
||||
"args": ["-y", "gitnexus@1.6.10", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@1.6.9", "mcp"]
|
||||
"args": ["-y", "gitnexus@1.6.10", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@1.6.9", "mcp"]
|
||||
"args": ["-y", "gitnexus@1.6.10", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
151
gitnexus-shared/src/impact-risk.ts
Normal file
151
gitnexus-shared/src/impact-risk.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
231
gitnexus-web/package-lock.json
generated
231
gitnexus-web/package-lock.json
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
209
gitnexus-web/test/unit/impact-tool.test.ts
Normal file
209
gitnexus-web/test/unit/impact-tool.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -58,9 +58,6 @@ packages: {}
|
|||
detect:
|
||||
http: true
|
||||
matching:
|
||||
bm25_threshold: 0.7
|
||||
embedding_threshold: 0.65
|
||||
max_candidates_per_step: 3
|
||||
`;
|
||||
}
|
||||
|
||||
|
|
|
|||
91
gitnexus/bench/v8-sidecar/measure.mjs
Normal file
91
gitnexus/bench/v8-sidecar/measure.mjs
Normal 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 });
|
||||
}
|
||||
97
gitnexus/package-lock.json
generated
97
gitnexus/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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> = {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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': '要分析的符号或文件名',
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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')}`);
|
||||
};
|
||||
|
|
|
|||
184
gitnexus/src/cli/watch-queue.ts
Normal file
184
gitnexus/src/cli/watch-queue.ts
Normal 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
501
gitnexus/src/cli/watch.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
117
gitnexus/src/config/repo-control-file.ts
Normal file
117
gitnexus/src/config/repo-control-file.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>>) || {};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -264,6 +264,7 @@ function bridgeCompletenessFor(
|
|||
return crossRepoCompleteness({
|
||||
unreadableRepos: meta.unreadableRepos,
|
||||
missingRepos: meta.missingRepos,
|
||||
suppressedMatchStages: meta.suppressedMatchStages,
|
||||
provenanceUnknown: bridgeProvenanceUnknown(meta),
|
||||
inScope,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
707
gitnexus/src/core/group/extractors/graphql-extractor.ts
Normal file
707
gitnexus/src/core/group/extractors/graphql-extractor.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
83
gitnexus/src/core/incremental/derived-writeback.ts
Normal file
83
gitnexus/src/core/incremental/derived-writeback.ts
Normal 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),
|
||||
);
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
170
gitnexus/src/core/index-content-drift.ts
Normal file
170
gitnexus/src/core/index-content-drift.ts
Normal 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' };
|
||||
}
|
||||
};
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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' : '';
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> = {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue