diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index 6494fac53..77a48e8ce 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -6,7 +6,7 @@ "plugins": [ { "name": "gitnexus", - "version": "1.6.9", + "version": "1.6.10", "source": { "source": "local", "path": "./gitnexus-claude-plugin" diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 576586d48..717a4c132 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -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." } diff --git a/.claude/skills/gitnexus-cli/SKILL.md b/.claude/skills/gitnexus-cli/SKILL.md index 342e8b08f..be02d92fd 100644 --- a/.claude/skills/gitnexus-cli/SKILL.md +++ b/.claude/skills/gitnexus-cli/SKILL.md @@ -21,13 +21,20 @@ Run from the project root. This parses all source files, builds the knowledge gr | Flag | Effect | | -------------- | ---------------------------------------------------------------- | +| `--watch` | Keep a Git repository index current with serialized refreshes | +| `--debounce ` | Watch quiet period before refresh (default: 300 ms) | | `--force` | Force full re-index even if up to date | | `--embeddings` | Enable embedding generation for semantic search (off by default) | | `--drop-embeddings` | Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` preserves them. | | `--pdg` | Build the program-dependence layers used by `explain` and `pdg_query` (taint, CDG, and REACHING_DEF). | +| `--spring-actuator ` | Import opt-in Spring Boot Actuator mappings, beans, conditions, configprops, and env snapshots. Forces a full rebuild; unsupported with `--watch`. | **When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook detects staleness after `git commit` and `git merge` and notifies the agent to run `analyze` — the hook does not run analyze itself, to avoid blocking the agent for up to 120s and risking KuzuDB corruption on timeout. +For Spring runtime enrichment, pass a JSON bundle, one endpoint JSON file, or a directory containing endpoint files. Route evidence is authoritative only when `runtimeConfirmed === true`; `runtimeSource` records provenance and may also accompany `handler-conflict`. Env/configprops values are never persisted. + +Use `node .gitnexus/run.cjs analyze --watch` for a long-lived local Git repository. It performs an initial analysis, queues scanner-admitted file changes, and retries intact failed batches with bounded backoff. Watch refreshes update only the graph: they skip AGENTS.md / CLAUDE.md injection and standard skill installation, so run a one-shot `analyze` when those generated files need updating. Watch rejects one-shot or context-output flags including `--force`, embedding flags, `--skills`, `--default-branch`, `--skip-agents-md`, `--skip-skills`, `--no-stats`, `--self-commit`, `--index-only`, and `--skip-git`. It never pulls remotes. Scheduled remote clone/pull is a different command: `gitnexus auto-sync`. Bare `gitnexus watch` is reserved and does not start either job. Running MCP and `serve` processes periodically check for a published replacement and reopen it without a restart. MCP checks are throttled to once every five seconds, so a tool call before the next check can briefly use the previous index. + ### status — Check index freshness ```bash @@ -55,15 +62,19 @@ Deletes the `.gitnexus/` directory and unregisters the repo from the global regi node .gitnexus/run.cjs wiki ``` -Generates repository documentation from the knowledge graph using an LLM. Requires an API key (saved to `~/.gitnexus/config.json` on first use). +Generates repository documentation from the knowledge graph using an LLM. HTTP providers require an API key (saved to `~/.gitnexus/config.json` on first use). Local CLI providers (`--provider cursor|claude|codex|opencode|grok`) use your existing CLI login. | Flag | Effect | | ------------------- | ----------------------------------------- | -| `--force` | Force full regeneration | -| `--model ` | LLM model (default: minimax/minimax-m2.5) | +| `--force` | Force full regeneration, also required to re-generate an existing wiki in a different language | +| `--provider ` | 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 ` | LLM model (default: MiniMax-M3) | | `--base-url ` | LLM API base URL | | `--api-key ` | LLM API key | | `--concurrency ` | Parallel LLM calls (default: 3) | +| `--timeout ` | LLM request timeout in seconds (default: disabled) | +| `--retries ` | Max LLM retry attempts per request (default: 3) | +| `--lang ` | Output language for generated documentation (e.g. english, chinese, spanish, japanese) | | `--gist` | Publish wiki as a public GitHub Gist | ### list — Show all indexed repos @@ -82,5 +93,5 @@ Lists all repositories registered in `~/.gitnexus/registry.json`. The MCP `list_ ## Troubleshooting - **"Not inside a git repository"**: Run from a directory inside a git repo -- **Index is stale after re-analyzing**: Restart Claude Code to reload the MCP server +- **Index is stale after re-analyzing**: Wait for the next MCP tool call to reopen the published index; this normally takes no more than five seconds - **Embeddings slow**: Omit `--embeddings` (it's off by default) or set `OPENAI_API_KEY` for faster API-based embedding diff --git a/.claude/skills/gitnexus-debugging/SKILL.md b/.claude/skills/gitnexus-debugging/SKILL.md index 4a33e589a..41fb568f8 100644 --- a/.claude/skills/gitnexus-debugging/SKILL.md +++ b/.claude/skills/gitnexus-debugging/SKILL.md @@ -13,9 +13,28 @@ description: "Use when the user is debugging a bug, tracing an error, or asking - "This endpoint returns 500" - Investigating bugs, errors, or unexpected behavior +## Bind the repository first + +A root cause traced in the wrong repository is a wrong root cause. + +Call `list_repos {}` before the first tool call. With one indexed repository, +use the examples below as written. With more than one, pass `repo` on every +call: an omitted `repo` normally errors, but under an MCP policy with a +configured default it resolves to that default silently. If you cannot tell +which repository is meant, stop and ask. This matters most for `cypher`, whose +statement carries no in-band hint of which database it ran against. + +`list_repos` is paginated, so page with `offset: pagination.nextOffset` until +`hasMore` is false before concluding a repository is absent. + +A stale index describes the code from before your bug, so refresh before +trusting a trace, and state the repository and index freshness with the +diagnosis. + ## Workflow ``` +0. list_repos {} → Bind repo 1. query({search_query: ""}) → Find related execution flows 2. context({name: ""}) → See callers/callees/processes 3. READ gitnexus://repo/{name}/process/{name} → Trace execution flow @@ -27,6 +46,7 @@ description: "Use when the user is debugging a bug, tracing an error, or asking ## Checklist ``` +- [ ] list_repos {} — bind repo; explicit repo when >1 indexed, ask if ambiguous - [ ] Understand the symptom (error message, unexpected behavior) - [ ] query for error text or related code - [ ] Identify the suspect function from returned processes @@ -34,6 +54,7 @@ description: "Use when the user is debugging a bug, tracing an error, or asking - [ ] Trace execution flow via process resource if applicable - [ ] cypher for custom call chain traces if needed - [ ] Read source files to confirm root cause +- [ ] State the repository and index freshness with the diagnosis ``` ## Debugging Patterns @@ -44,7 +65,7 @@ description: "Use when the user is debugging a bug, tracing an error, or asking | Wrong return value | `context` on the function → trace callees for data flow | | Intermittent failure | `context` → look for external calls, async deps | | Performance issue | `context` → find symbols with many callers (hot paths) | -| Recent regression | `detect_changes` to see what your changes affect | +| Recent regression | `detect_changes` to see what your changes affect — pass `worktree` for a linked worktree | | "How does A reach B?" | `trace` between the two symbols — shortest call chain in one call | ## Tools @@ -52,7 +73,7 @@ description: "Use when the user is debugging a bug, tracing an error, or asking **query** — find code related to error: ``` -query({search_query: "payment validation error"}) +query({search_query: "payment validation error", repo: "my-app"}) → Processes: CheckoutFlow, ErrorHandling → Symbols: validatePayment, handlePaymentError, PaymentException ``` @@ -60,13 +81,15 @@ query({search_query: "payment validation error"}) **context** — full context for a suspect: ``` -context({name: "validatePayment"}) +context({name: "validatePayment", repo: "my-app"}) → Incoming calls: processCheckout, webhookHandler → Outgoing calls: verifyCard, fetchRates (external API!) → Processes: CheckoutFlow (step 3/7) ``` -**cypher** — custom call chain traces: +**cypher** — custom call chain traces. Pass `repo` alongside the statement; the +Cypher text itself names no repository, so the result is unattributable without +it: ```cypher MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Function {name: "validatePayment"}) @@ -76,7 +99,7 @@ RETURN [n IN nodes(path) | n.name] AS chain **trace** — shortest call chain between two symbols ("how does A reach B?"), one call instead of chaining `context` hops: ``` -trace({ from: "processCheckout", to: "fetchRates" }) +trace({ from: "processCheckout", to: "fetchRates", repo: "my-app" }) → status: ok, hopCount: 3 → hops: processCheckout → validatePayment → verifyCard → fetchRates → edges: CALLS (1.0), CALLS (0.95), CALLS (1.0) @@ -87,15 +110,22 @@ When no path exists, `trace` reports the furthest reachable node — exactly whe ## Example: "Payment endpoint returns 500 intermittently" ``` -1. query({search_query: "payment error handling"}) +0. list_repos {} + → total: 2 (my-app, billing-api) — bind my-app explicitly on every call + +1. query({search_query: "payment error handling", repo: "my-app"}) → Processes: CheckoutFlow, ErrorHandling → Symbols: validatePayment, handlePaymentError -2. context({name: "validatePayment"}) +2. context({name: "validatePayment", repo: "my-app"}) → Outgoing calls: verifyCard, fetchRates (external API!) 3. READ gitnexus://repo/my-app/process/CheckoutFlow → Step 3: validatePayment → calls fetchRates (external) 4. Root cause: fetchRates calls external API without proper timeout + Repository: my-app Index: current ``` + +With a single indexed repository, step 0 returns `total: 1` and the `repo` +argument drops out of every call above. diff --git a/.claude/skills/gitnexus-exploring/SKILL.md b/.claude/skills/gitnexus-exploring/SKILL.md index f483c2fd6..46fc187ce 100644 --- a/.claude/skills/gitnexus-exploring/SKILL.md +++ b/.claude/skills/gitnexus-exploring/SKILL.md @@ -13,10 +13,22 @@ description: "Use when the user asks how code works, wants to understand archite - "Where is the database logic?" - Understanding code you haven't seen before +## Bind the repository first + +Step 1 discovers what is indexed; every call after it must say which of those +it means. With one indexed repository, use the examples below as written. With +more than one, pass `repo` on every call: an omitted `repo` normally errors, +but under an MCP policy with a configured default it resolves to that default +silently. If you cannot tell which repository is meant, stop and ask. Report +the bound repository and index freshness alongside your explanation. + +`list_repos` is paginated, so page with `offset: pagination.nextOffset` until +`hasMore` is false before concluding a repository is absent. + ## Workflow ``` -1. READ gitnexus://repos → Discover indexed repos +1. list_repos {} or READ gitnexus://repos → Discover indexed repos 2. READ gitnexus://repo/{name}/context → Codebase overview, check staleness 3. query({search_query: ""}) → Find related execution flows 4. context({name: ""}) → Deep dive on specific symbol @@ -28,12 +40,14 @@ description: "Use when the user asks how code works, wants to understand archite ## Checklist ``` +- [ ] list_repos {} — bind repo; explicit repo when >1 indexed, ask if ambiguous - [ ] READ gitnexus://repo/{name}/context - [ ] query for the concept you want to understand - [ ] Review returned processes (execution flows) - [ ] context on key symbols for callers/callees - [ ] READ process resource for full execution traces - [ ] Read source files for implementation details +- [ ] State the repository and index freshness with the explanation ``` ## Resources @@ -50,7 +64,7 @@ description: "Use when the user asks how code works, wants to understand archite **query** — find execution flows related to a concept: ``` -query({search_query: "payment processing"}) +query({search_query: "payment processing", repo: "my-app"}) → Processes: CheckoutFlow, RefundFlow, WebhookHandler → Symbols grouped by flow with file locations ``` @@ -58,16 +72,20 @@ query({search_query: "payment processing"}) **context** — 360-degree view of a symbol: ``` -context({name: "validateUser"}) +context({name: "validateUser", repo: "my-app"}) → Incoming calls: loginHandler, apiMiddleware → Outgoing calls: checkToken, getUserById → Processes: LoginFlow (step 2/5), TokenRefresh (step 1/3) ``` +`repo` is required once more than one repository is indexed, and may be omitted +with a single one. + ## Example: "How does payment processing work?" ``` -1. READ gitnexus://repo/my-app/context → 918 symbols, 45 processes +1. list_repos {} → total: 1 (my-app) — bind it + READ gitnexus://repo/my-app/context → 918 symbols, 45 processes 2. query({search_query: "payment processing"}) → CheckoutFlow: processPayment → validateCard → chargeStripe → RefundFlow: initiateRefund → calculateRefund → processRefund @@ -75,4 +93,8 @@ context({name: "validateUser"}) → Incoming: checkoutHandler, webhookHandler → Outgoing: validateCard, chargeStripe, saveTransaction 4. Read src/payments/processor.ts for implementation details +5. Answer, noting: Repository my-app, index current ``` + +Had step 1 returned two repositories, every call above would carry +`repo: "my-app"`. diff --git a/.claude/skills/gitnexus-impact-analysis/SKILL.md b/.claude/skills/gitnexus-impact-analysis/SKILL.md index 2e34f86f6..85d90c90d 100644 --- a/.claude/skills/gitnexus-impact-analysis/SKILL.md +++ b/.claude/skills/gitnexus-impact-analysis/SKILL.md @@ -14,13 +14,42 @@ description: "Use when the user wants to know what will break if they change som - Before making non-trivial code changes - Before committing — to understand what your changes affect +## Bind the repository first + +Impact analysis is the gate that authorizes an edit, so it must answer for the +repository you are about to edit. + +Call `list_repos {}` before the first tool call. With one indexed repository, +use the examples below as written. With more than one, pass `repo` on every +call: an omitted `repo` normally errors, but under an MCP policy with a +configured default it resolves to that default silently. If you cannot tell +which repository is meant, stop and ask — every result below an ambiguous +identity inherits the ambiguity. `list_repos` is paginated, so page with +`offset: pagination.nextOffset` until `hasMore` is false before concluding a +repository is absent. + +`detect_changes` takes `worktree` when your changes are in a linked worktree +the MCP server was not launched from. The server auto-detects a worktree only +when it was launched from inside one; otherwise `git diff` runs in the wrong +checkout and reports zero changed symbols — a false clean check that carries +none of the degradation flags described below. In the CLI fallbacks, `--repo .` +means the current checkout; pass the intended repository path instead when you +are not standing in it. + +State the bound identity with your risk report: + +``` +Repository: () Worktree: Index: , behind HEAD +``` + ## Workflow ``` +0. list_repos {} → Bind repo (and worktree) 1. impact({target: "X", direction: "upstream"}) or `node .gitnexus/run.cjs impact "X" --direction upstream --repo .` 2. READ gitnexus://repo/{name}/processes → Check affected execution flows 3. detect_changes({scope: "all"}) or `node .gitnexus/run.cjs detect-changes --scope all --repo .` -4. Assess risk and report to user +4. Assess risk and report to user, echoing repo/worktree/index identity ``` > If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal. @@ -29,12 +58,14 @@ description: "Use when the user wants to know what will break if they change som ## Checklist ``` +- [ ] list_repos {} — bind repo; explicit repo when >1 indexed, ask if ambiguous - [ ] impact({target, direction: "upstream"}) or CLI fallback to find dependents - [ ] Review d=1 items first (these WILL BREAK) - [ ] Check high-confidence (>0.8) dependencies - [ ] READ processes to check affected execution flows - [ ] detect_changes({scope: "all"}) or CLI fallback for pre-commit check -- [ ] Assess risk level and report to user +- [ ] Confirm the checkout you edited is the checkout that was diffed +- [ ] Assess risk level and report, stating repo/worktree/index identity ``` ## Understanding Output @@ -62,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 --direction upstream --repo .` instead: @@ -69,6 +109,7 @@ treating the symbol as safe to change or delete. ``` impact({ target: "validateUser", + repo: "my-app", // required once >1 repository is indexed direction: "upstream", minConfidence: 0.8, maxDepth: 3 @@ -92,10 +133,26 @@ detect_changes({scope: "all"}) → Risk: MEDIUM ``` +Add `repo` once more than one repository is indexed, and `worktree: ""` when your changes are in a linked worktree the server was not launched +from. + +`partial: true` (a graph query failed) or `truncated: true` (the changed-symbol +listing was capped) means the result is short of the truth, and reads like +`UNKNOWN` above: a zero there means unseen, not unaffected. Re-run it rather +than tick the pre-commit check. + +A wrong-worktree zero carries neither flag and is shape-identical to a genuine +clean result, so confirm the checkout you edited is the one that was diffed +before treating an empty change set as a passed check. + ## Example: "What breaks if I change validateUser?" ``` -1. impact({target: "validateUser", direction: "upstream"}) or `node .gitnexus/run.cjs impact "validateUser" --direction upstream --repo .` +0. list_repos {} + → total: 2 (my-app, billing-api) — both define validateUser, so bind explicitly + +1. impact({target: "validateUser", repo: "my-app", direction: "upstream"}) or `node .gitnexus/run.cjs impact "validateUser" --direction upstream --repo .` → d=1: loginHandler, apiMiddleware (WILL BREAK) → d=2: authRouter, sessionManager (LIKELY AFFECTED) @@ -103,4 +160,8 @@ detect_changes({scope: "all"}) → LoginFlow and TokenRefresh touch validateUser 3. Risk: 2 direct callers, 2 processes = MEDIUM + Repository: my-app (/abs/path/my-app) Worktree: same Index: current ``` + +With a single indexed repository, step 0 returns `total: 1` and the `repo` +argument drops out of every call above. diff --git a/.claude/skills/gitnexus-plan/README.md b/.claude/skills/gitnexus-plan/README.md index f7fe58ab9..153374bb7 100644 --- a/.claude/skills/gitnexus-plan/README.md +++ b/.claude/skills/gitnexus-plan/README.md @@ -124,12 +124,17 @@ phase that needs them. statement-level claims (never reconstructs fake edges). - No GitNexus at all → fallback mode: targeted grep/read exploration, findings labelled **source-derived**, with a recommendation to index. -- Reading or publishing a plan requires Linux `/proc/self/fd`, `O_DIRECTORY`, - and `O_NOFOLLOW`; publication also requires a validated absolute Python 3 - PATH candidate with libc `renameat2(RENAME_NOREPLACE)` support, a - writable target repository, and a shared filesystem for the plan and - Git-admin vault. The writer fails closed when those guarantees are - unavailable; it never redirects the plan elsewhere. +- Reading or publishing a plan requires `O_DIRECTORY` and `O_NOFOLLOW`, plus + `/proc/self/fd` on Linux; every other platform is refused. No interpreter is + spawned and no native code is loaded. Publication is `link(2)`, which fails + rather than replaces when the destination name is taken. Linux resolves every + name against a held descriptor, so a parent swapped mid-write cannot redirect + the operation; macOS has no equivalent path and instead pins each directory + with an open descriptor and re-proves the chain either side of every step, + which detects such a swap and aborts. Publishing also needs a writable target + repository and a shared filesystem for the plan and Git-admin vault. The + writer fails closed when those guarantees are unavailable; it never redirects + the plan elsewhere. ## Limitations diff --git a/.claude/skills/gitnexus-plan/references/evidence-provenance.md b/.claude/skills/gitnexus-plan/references/evidence-provenance.md index c686599da..3df5a046d 100644 --- a/.claude/skills/gitnexus-plan/references/evidence-provenance.md +++ b/.claude/skills/gitnexus-plan/references/evidence-provenance.md @@ -98,8 +98,11 @@ excluded. ## Safe existing-plan read contract -`read-plan` fails closed unless Linux `/proc/self/fd`, `O_DIRECTORY`, and -`O_NOFOLLOW` are available. It resolves the exact Git top-level, opens the +`read-plan` fails closed unless the host platform can resolve names against a +held directory descriptor: Linux `/proc/self/fd` with `O_DIRECTORY` and +`O_NOFOLLOW`, or macOS `O_DIRECTORY`/`O_NOFOLLOW`. Every other platform is +refused outright — an unverified read is not a degraded read, it is a different, +racy operation. It resolves the exact Git top-level, opens the repository root and every plan parent as held no-follow directory descriptors, rejects missing, symlink, non-directory, and escaping parents, and opens the leaf with `O_NOFOLLOW`. It reads at most 16 MiB from that held file descriptor, @@ -109,13 +112,17 @@ Neither Deepen nor work may parse bytes obtained before or outside this receipt. ## Safe generated-plan write contract -The writer fails closed unless Linux `/proc/self/fd`, `O_DIRECTORY`, -`O_NOFOLLOW`, and Python 3 with libc `renameat2(RENAME_NOREPLACE)` support are -available. Python may live in `/usr/local`, a Nix profile, or another absolute -PATH directory, but the helper accepts only a resolved executable and -containing directory owned by root or the current user and not writable by -group/other. The resolved executable is opened without following links and -invoked through that held descriptor. Relative PATH entries are ignored. The plan parent and the +The writer fails closed unless the host platform offers `O_DIRECTORY` and +`O_NOFOLLOW`, plus `/proc/self/fd` on Linux. It spawns no interpreter and loads +no native code: publication is `link(2)`, which is atomic, fails `EEXIST` when +the destination name is taken, and refuses a symlinked destination without +following it — the same no-replace guarantee `renameat2(RENAME_NOREPLACE)` and +`renameatx_np(RENAME_EXCL)` provide, available through `fs.linkSync` on every +supported platform. The temporary name is unlinked once the link succeeds; the +published file is the same inode the writer created and verified, so every +identity check downstream holds by construction. A link that succeeds followed +by an unlink that fails leaves the plan published and is reported as success, +because it is one. The plan parent and the repository's Git-admin directory must also share a filesystem. It resolves the target repository's exact Git top-level, opens that root and every destination parent as held no-follow directory descriptors, creates missing @@ -128,15 +135,45 @@ The writer creates a random exclusive temporary file relative to the held final parent descriptor and keeps its no-follow descriptor open. It writes and flushes the bytes, binds the temporary name to the opened inode, and hashes the open file before publication. Immediately before publication it revalidates -the parent and the temporary path, inode, size, and digest. Publication uses an -atomic no-replace move relative to the held directory descriptor. Initial mode -therefore cannot overwrite a destination that appears after the absent check. +the parent and the temporary path, inode, size, and digest. Publication links +the temporary name to the destination relative to the held directory +descriptor, which fails rather than replaces if the destination is taken. +Initial mode therefore cannot overwrite a destination that appears after the +absent check. The writer then flushes the directory and revalidates the committed path by opening it with `O_NOFOLLOW`, hashing both the original temporary fd and the path-bound fd, and performing a second descriptor-anchored path identity check after hashing. A detected mutation or replacement aborts instead of accepting mixed-era output. +### Linux anchors, macOS verifies + +The two platforms reach the same destination by different proofs, and the +difference is real enough to state rather than smooth over. + +On Linux every name resolves through `/proc/self/fd//`, a magic link +the kernel resolves against the inode the descriptor already holds. The names +above it are never re-walked, so an attacker who renames a parent between the +check and the use cannot redirect the operation. The race is impossible, not +merely detected. + +macOS has no such path. `/dev/fd/` is a devfs node, not a magic link: it can +be opened, but nothing can be resolved through it. `open("/dev/fd//child")` +returns `ENOENT`, and `realpath` of it returns `/dev/fd/` rather than the +directory's path — measured on macOS 26, not inferred. Node exposes no `openat`, +no `dir_fd` parameter, and no FFI, so on macOS the writer resolves names +lexically with `O_NOFOLLOW` at every component, holds an open descriptor on +every directory in the chain for the whole operation, and proves before *and* +after each step that the chain still names exactly the inodes it is holding. +Holding the descriptors is what makes the recorded inode numbers trustworthy: +an open descriptor pins its inode, so a freed number cannot be recycled beneath +the walk. + +What that buys is detection rather than prevention. A parent swapped inside the +window between a check and its use is caught by the check that follows, and the +operation aborts having written nothing — but on Linux it could not have +happened at all. No published byte escapes verification on either platform. + `--replace` accepts only a pre-existing regular file and is reserved for Deepen; without it, accidental overwrite is rejected. It also requires the exact canonical `generated_plan_path` and `plan_digest` from the same session's diff --git a/.claude/skills/gitnexus-plan/scripts/evidence-provenance.mjs b/.claude/skills/gitnexus-plan/scripts/evidence-provenance.mjs index 181d2120b..793fe4cd8 100644 --- a/.claude/skills/gitnexus-plan/scripts/evidence-provenance.mjs +++ b/.claude/skills/gitnexus-plan/scripts/evidence-provenance.mjs @@ -479,11 +479,11 @@ function resolveOwnGitTopLevel(absolute) { if (result.status !== 0) return null; let topLevel; try { - topLevel = fs.realpathSync(decodeUtf8(result.stdout, 'nested repository root').trim()); + topLevel = fs.realpathSync.native(decodeUtf8(result.stdout, 'nested repository root').trim()); } catch { return null; } - return topLevel === fs.realpathSync(absolute) ? topLevel : null; + return topLevel === fs.realpathSync.native(absolute) ? topLevel : null; } function readOwnGitlinkHead(absolute) { @@ -616,17 +616,30 @@ function filesystemObject(absolute, expectedKind, mutationGuards, testHooks) { throw new Error(`Unsupported filesystem object at ${absolute}`); } -function guardPathParents(repo, repoPath, mutationGuards) { +// Every dirty path re-walks its own parents, and dirty paths overwhelmingly +// share them — the repository root is re-stat'ed once per path. `guarded` is +// per-snapshot and remembers which absolute directories already carry a guard, +// so each distinct directory is stat'ed and guarded exactly once. +// +// Keeping the first-seen identity is the conservative choice: verifyGuards +// re-checks every guard against the filesystem at the end, so a directory that +// changes after it was guarded still fails there. Skipping a re-stat cannot hide +// a change; it only avoids recording the same directory twice. +function guardPathParents(repo, repoPath, mutationGuards, guarded) { const components = repoPath.split('/'); let current = repo; - const rootStat = fs.lstatSync(repo, { bigint: true }); - mutationGuards.push({ - type: 'directory', - absolute: repo, - identity: stableDirectoryIdentity(rootStat), - }); + if (!guarded.has(repo)) { + guarded.add(repo); + mutationGuards.push({ + type: 'directory', + absolute: repo, + identity: stableDirectoryIdentity(fs.lstatSync(repo, { bigint: true })), + }); + } for (const component of components.slice(0, -1)) { current = path.join(current, component); + // Already proved a real directory and already guarded on an earlier path. + if (guarded.has(current)) continue; let stat; try { stat = fs.lstatSync(current, { bigint: true }); @@ -638,6 +651,7 @@ function guardPathParents(repo, repoPath, mutationGuards) { throw new Error(`Refusing to traverse symlink parent for ${repoPath}`); } if (!stat.isDirectory()) return; + guarded.add(current); mutationGuards.push({ type: 'directory', absolute: current, @@ -646,81 +660,153 @@ function guardPathParents(repo, repoPath, mutationGuards) { } } -function recordAnchoredAbsence(repo, repoPath, mutationGuards) { - requireDescriptorAnchoring(); - const flags = - fs.constants.O_RDONLY | - fs.constants.O_DIRECTORY | - fs.constants.O_NOFOLLOW | - (fs.constants.O_CLOEXEC ?? 0); - const descriptors = []; - let retainedFd; - try { - let currentFd = fs.openSync(repo, flags); - descriptors.push(currentFd); - const components = repoPath.split('/'); - for (let index = 0; index < components.length; index += 1) { - const component = components[index]; - const child = descriptorPath(currentFd, component); - let childStat; - try { - childStat = fs.lstatSync(child, { bigint: true }); - } catch (error) { - if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') throw error; - const parentStat = fs.fstatSync(currentFd, { bigint: true }); - if (!parentStat.isDirectory()) { - throw new Error(`Absence parent is no longer a directory for ${repoPath}`); - } - retainedFd = currentFd; - mutationGuards.push({ - type: 'absence', - fd: retainedFd, - childName: component, - repoPath, - parentIdentity: stableDirectoryIdentity(parentStat), - parentMutationIdentity: statIdentity(parentStat), - }); - for (const fd of descriptors) { - if (fd !== retainedFd) fs.closeSync(fd); - } - return; - } - if (index === components.length - 1) { - throw new Error(`${repoPath} appeared while its absence was being anchored`); - } - if (childStat.isSymbolicLink() || !childStat.isDirectory()) { - throw new Error(`Refusing a non-directory parent while anchoring absence for ${repoPath}`); - } - const nextFd = fs.openSync(child, flags); - descriptors.push(nextFd); - currentFd = nextFd; - } - throw new Error(`Could not anchor absence for ${repoPath}`); - } catch (error) { - for (const fd of descriptors) { - if (fd === retainedFd) continue; - try { - fs.closeSync(fd); - } catch { - // Preserve the primary absence-anchoring error. - } - } - throw error; +// A bound, not a bug: the absence cache deduplicates correctly and leaks nothing, +// but citedPaths is caller-supplied and unbounded, so a pathological snapshot +// could hold more descriptors than the process is allowed (macOS +// kern.maxfilesperproc is 24576). The peak precedes a `git` spawn, so exhaustion +// would surface as a git failure misreported as evidence instability. +// +// Refuse rather than evict: closing a cached descriptor would silently break the +// pinned chain of an absence guard that was already recorded against it, which is +// exactly the inode-recycling hole the pins exist to close. +const ABSENCE_ANCHOR_LIMITS = Object.freeze({ maxPinnedDirectories: 4096 }); + +// Every no-follow read and every exclusive create in this file uses one of these +// two, so a change lands in one place rather than in seven. +const VERIFIED_READ_FLAGS = + fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_CLOEXEC ?? 0); +const VERIFIED_CREATE_FLAGS = + fs.constants.O_RDWR | + fs.constants.O_CREAT | + fs.constants.O_EXCL | + fs.constants.O_NOFOLLOW | + (fs.constants.O_CLOEXEC ?? 0); + +function requireAbsenceAnchorCapacity(cache) { + if (cache.size >= ABSENCE_ANCHOR_LIMITS.maxPinnedDirectories) { + throw new Error( + `Absence anchoring exceeds ${ABSENCE_ANCHOR_LIMITS.maxPinnedDirectories} pinned directories`, + ); } } -function materializeRecord(repo, statusRecord, layers, mutationGuards, testHooks) { +const ANCHORED_DIRECTORY_FLAGS = + fs.constants.O_RDONLY | + fs.constants.O_DIRECTORY | + fs.constants.O_NOFOLLOW | + (fs.constants.O_CLOEXEC ?? 0); + +// Every absence receipt is verified long after its walk returns, so the chain +// that produced it has to stay pinned until the snapshot ends — an unpinned inode +// number can be recycled by a replacement directory that then reproduces the +// recorded identity exactly. Absent cited paths overwhelmingly share prefixes, so +// the walked directories are cached per snapshot and keyed by repo-relative +// prefix: one open descriptor and one anchored walk per distinct directory rather +// than per path. snapshotEvidence owns every descriptor in this cache and closes +// each exactly once; guards only borrow them for verification. +function anchoredAbsenceRoot(repo, cache) { + const cached = cache.get(''); + if (cached) return cached; + requireAbsenceAnchorCapacity(cache); + const fd = openVerifiedDirectory(repo, ANCHORED_DIRECTORY_FLAGS); + const handle = { + fd, + expectedPath: repo, + chain: [ + { expectedPath: repo, identity: stableDirectoryIdentity(fs.fstatSync(fd, { bigint: true })) }, + ], + descriptors: [fd], + }; + cache.set('', handle); + return handle; +} + +function recordAnchoredAbsence(repo, repoPath, mutationGuards, cache) { + requireDescriptorAnchoring(); + const components = repoPath.split('/'); + let handle = anchoredAbsenceRoot(repo, cache); + let prefix = ''; + for (let index = 0; index < components.length; index += 1) { + const component = components[index]; + const isFinal = index === components.length - 1; + prefix = prefix === '' ? component : `${prefix}/${component}`; + // The final component is always re-checked against the filesystem: it is the + // one whose absence is being recorded, and a cached answer would be a stale + // one. Only the prefix directories are reused. + const cached = isFinal ? undefined : cache.get(prefix); + if (cached) { + handle = cached; + continue; + } + const child = anchoredChild(handle, component); + let childStat; + try { + childStat = lstatChild(child); + } catch (error) { + if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') throw error; + const parentStat = fs.fstatSync(handle.fd, { bigint: true }); + if (!parentStat.isDirectory()) { + throw new Error(`Absence parent is no longer a directory for ${repoPath}`); + } + mutationGuards.push({ + type: 'absence', + // The handle is the holder the guard verifies against, and `ref` is the + // child path already built through the anchoredChild chokepoint — the + // guard must never re-derive that name itself. + handle, + ref: child, + fd: handle.fd, + repoPath, + parentMutationIdentity: statIdentity(parentStat), + }); + return; + } + if (isFinal) { + throw new Error(`${repoPath} appeared while its absence was being anchored`); + } + if (childStat.isSymbolicLink() || !childStat.isDirectory()) { + throw new Error(`Refusing a non-directory parent while anchoring absence for ${repoPath}`); + } + requireAbsenceAnchorCapacity(cache); + const childFd = openVerifiedDirectory(child.path, ANCHORED_DIRECTORY_FLAGS); + const expectedPath = path.join(handle.expectedPath, component); + let next; + try { + if (!anchoringBackend().descriptorMatchesChild(childFd, expectedPath, childStat)) { + throw new Error( + `Absence parent descriptor does not match its verified inode for ${repoPath}`, + ); + } + next = { + fd: childFd, + expectedPath, + chain: [...handle.chain, { expectedPath, identity: stableDirectoryIdentity(childStat) }], + descriptors: [...handle.descriptors, childFd], + }; + } catch (error) { + fs.closeSync(childFd); + throw error; + } + cache.set(prefix, next); + handle = next; + } + throw new Error(`Could not anchor absence for ${repoPath}`); +} + +function materializeRecord(repo, statusRecord, layers, mutationGuards, testHooks, walkState) { const head = layers.head(statusRecord.path); const index = layers.index(statusRecord.path); const expectedKind = index.kind === 'gitlink' || head.kind === 'gitlink' ? 'gitlink' : null; - guardPathParents(repo, statusRecord.path, mutationGuards); + guardPathParents(repo, statusRecord.path, mutationGuards, walkState.guardedDirectories); const filesystem = filesystemObject( path.join(repo, ...statusRecord.path.split('/')), expectedKind, mutationGuards, testHooks, ); - if (filesystem.kind === ABSENT) recordAnchoredAbsence(repo, statusRecord.path, mutationGuards); + if (filesystem.kind === ABSENT) { + recordAnchoredAbsence(repo, statusRecord.path, mutationGuards, walkState.absenceCache); + } if (statusRecord.directory_hint && filesystem.kind !== 'directory') { throw new Error( `Git reported an embedded directory but found ${filesystem.kind}: ${statusRecord.path}`, @@ -789,9 +875,15 @@ export function serializeDirtyRecords(entries) { } function assertRepository(repoInput) { - const repo = fs.realpathSync(requireString(repoInput, 'repo')); + // realpathSync.native, not realpathSync: the JS resolver preserves a Windows + // 8.3 short component (C:\Users\RUNNER~1\...) while git always reports the long + // form, so the two would never compare equal and every caller would be told the + // worktree root is not the worktree root it just named. + const repo = fs.realpathSync.native(requireString(repoInput, 'repo')); const topLevelResult = git(repo, ['rev-parse', '--show-toplevel']); - const topLevel = fs.realpathSync(decodeUtf8(topLevelResult.stdout, 'repository root').trim()); + const topLevel = fs.realpathSync.native( + decodeUtf8(topLevelResult.stdout, 'repository root').trim(), + ); if (topLevel !== repo) throw new Error(`--repo must be the Git worktree root (${topLevel})`); return repo; } @@ -882,17 +974,48 @@ function stableFileIdentity(stat) { return [stat.dev, stat.ino, stat.mode, stat.size].map(String).join(':'); } +// The two backends below differ in one decisive way, and it is worth stating +// plainly because the security properties are not the same. +// +// Linux ANCHORS. A name is resolved through /proc/self/fd//, which +// starts the walk at the inode the descriptor holds, so a parent that is renamed +// away cannot be traversed at all: the descriptor keeps pointing at the original +// directory and the impostor planted at the same name is simply never reached. +// +// macOS VERIFIES. Node cannot resolve a name relative to a descriptor there — +// /dev/fd/ is not a magic link (it stats as the directory but every attempt +// to traverse a child through it returns ENOENT), and fcntl F_GETPATH is a +// name-cache snapshot rather than a live anchor. So the Darwin backend resolves +// lexically, holds an open descriptor on every element of the chain, and proves +// before and after each operation that the path chain still names exactly the +// inodes it is holding. That DETECTS a swapped parent and aborts the write; it +// does not make the swap impossible the way the Linux path does. A swap landing +// inside the window between a check and the call it guards is caught by the +// following check, after the fact, rather than being unreachable. +// +// Every other platform gets neither and is refused outright. function requireDescriptorAnchoring() { - if ( - process.platform !== 'linux' || - fs.constants.O_DIRECTORY === undefined || - fs.constants.O_NOFOLLOW === undefined || - !fs.existsSync('/proc/self/fd') - ) { - throw new Error( - 'Safe generated-plan writes require Linux /proc/self/fd and O_DIRECTORY/O_NOFOLLOW; refusing an unanchored write', - ); + const directoryFlagsAvailable = + fs.constants.O_DIRECTORY !== undefined && fs.constants.O_NOFOLLOW !== undefined; + if (process.platform === 'linux') { + if (!directoryFlagsAvailable || !fs.existsSync('/proc/self/fd')) { + throw new Error( + 'Safe generated-plan writes require Linux /proc/self/fd and O_DIRECTORY/O_NOFOLLOW; refusing an unanchored write', + ); + } + return; } + if (process.platform === 'darwin') { + if (!directoryFlagsAvailable) { + throw new Error( + 'Safe generated-plan writes require macOS O_DIRECTORY/O_NOFOLLOW; refusing an unverified write', + ); + } + return; + } + throw new Error( + `Safe generated-plan writes require Linux /proc/self/fd or macOS O_DIRECTORY/O_NOFOLLOW; ${process.platform} offers neither, so refusing an unanchored write`, + ); } function descriptorPath(fd, childName) { @@ -900,157 +1023,352 @@ function descriptorPath(fd, childName) { return childName === undefined ? base : path.join(base, childName); } -function externalDescriptorPath(fd, childName) { - const base = `/proc/${process.pid}/fd/${fd}`; - return childName === undefined ? base : path.join(base, childName); +// Directory opens are plain O_RDONLY|O_DIRECTORY|O_NOFOLLOW|O_CLOEXEC on both +// platforms, and deliberately nothing else. +// +// O_NOFOLLOW_ANY (macOS 11+) used to be ORed in here on the theory that XNU +// ignores unrecognized open flag bits, so it would be inert where unsupported. +// That was wrong: combined with O_DIRECTORY macOS rejects it outright with +// EINVAL, and every directory open on Darwin failed. It is gone and is not +// coming back behind a probe or a degrade-on-EINVAL path — the per-component +// O_NOFOLLOW walk is what delivers the guarantee. Rust's cap-std, the closest +// reference implementation of this problem, has not adopted O_NOFOLLOW_ANY +// either (their issue #179 is still open). +function openVerifiedDirectory(absolute, flags) { + return fs.openSync(absolute, flags); } -const RENAME_NOREPLACE_SCRIPT = String.raw` -import ctypes -import errno -import os -import sys - -libc = ctypes.CDLL(None, use_errno=True) -try: - renameat2 = libc.renameat2 -except AttributeError: - print("libc does not expose renameat2", file=sys.stderr) - raise SystemExit(125) - -renameat2.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint] -renameat2.restype = ctypes.c_int -result = renameat2(-100, os.fsencode(sys.argv[1]), -100, os.fsencode(sys.argv[2]), 1) -if result != 0: - error_number = ctypes.get_errno() - error_name = errno.errorcode.get(error_number, "UNKNOWN") - print(f"renameat2 RENAME_NOREPLACE failed: {error_name}: {os.strerror(error_number)}", file=sys.stderr) - raise SystemExit(17 if error_number == errno.EEXIST else 126) -`; - -let atomicMoverPath; - -function spawnHeldExecutable(executable, args, options) { - const before = fs.fstatSync(executable.fd, { bigint: true }); - if (!before.isFile() || statIdentity(before) !== executable.identity) { - throw new Error('Validated Python executable changed before invocation'); - } - const result = spawnSync('/proc/self/fd/3', args, { - ...options, - stdio: ['ignore', 'pipe', 'pipe', executable.fd], - }); - const after = fs.fstatSync(executable.fd, { bigint: true }); - assertStableIdentity(before, after, 'validated Python executable'); - return result; +// File opens additionally get O_NONBLOCK, which directory opens do not need: +// it stops a FIFO swapped in at the target name from wedging the process on +// open. The identity comparison that follows rejects the FIFO anyway, but only +// if we ever get as far as running it. +function openVerifiedFile(absolute, flags, mode) { + const nonBlocking = flags | (fs.constants.O_NONBLOCK ?? 0); + return mode === undefined + ? fs.openSync(absolute, nonBlocking) + : fs.openSync(absolute, nonBlocking, mode); } -function validatedPathExecutable(candidate) { - if (!path.isAbsolute(candidate)) return null; - const candidateDirectory = path.dirname(candidate); - let resolvedDirectory; - let resolved; - let directoryStats; - let executableStat; +// The publish primitive, identical on both platforms. +// +// link() is the portable no-replace publish: it fails with EEXIST if the +// destination name is taken — by a regular file, by a directory, or by a symlink, +// live or dangling — and it never follows that symlink to clobber its target. +// It also works where renameat2(RENAME_NOREPLACE) does not, notably v9fs, which +// is why the WSL2 9p case that used to fail every time now works. +// +// The published file is the same inode as the temporary, so every identity +// comparison the callers already make still holds, and validateCommittedPlan +// becomes strictly stronger: it compares the destination against the exact inode +// whose bytes were fsynced. +// +// On Linux both paths are /proc/self/fd//, so the publish is anchored +// to the held parent descriptors exactly like every other operation. +// link(2) BUGS: "On NFS filesystems, the return code may be wrong in case the NFS +// server performs the link creation and dies before it can say so. Use stat(2) to +// find out if the link got created." open(2) NOTES gives the remedy this +// implements: on a reported failure, stat the source and see whether its link +// count reached 2. A false positive would need someone to have hardlinked a +// 16-random-byte name inside a directory we hold open — and validateCommittedPlan +// still proves the destination is the exact temporary inode afterwards. +function linkCreatedDespiteError(sourcePath) { try { - resolvedDirectory = fs.realpathSync(candidateDirectory); - resolved = fs.realpathSync(candidate); - const resolvedExecutableDirectory = fs.realpathSync(path.dirname(resolved)); - directoryStats = [...new Set([resolvedDirectory, resolvedExecutableDirectory])].map( - (directory) => fs.statSync(directory), - ); - executableStat = fs.lstatSync(resolved); - fs.accessSync(resolved, fs.constants.X_OK); + return fs.statSync(sourcePath, { bigint: true }).nlink === 2n; } catch { - return null; + return false; } - if ( - directoryStats.some((stat) => !stat.isDirectory()) || - !executableStat.isFile() || - executableStat.isSymbolicLink() - ) { - return null; - } - const uid = typeof process.getuid === 'function' ? process.getuid() : null; - const trustedOwner = (stat) => uid === null || stat.uid === 0 || stat.uid === uid; - if ( - directoryStats.some((stat) => !trustedOwner(stat) || (stat.mode & 0o022) !== 0) || - !trustedOwner(executableStat) || - (executableStat.mode & 0o022) !== 0 - ) { - return null; - } - return resolved; } -function resolveAtomicMover() { - if (atomicMoverPath) return atomicMoverPath; - const candidates = new Set(); - for (const entry of (process.env.PATH ?? '').split(path.delimiter)) { - if (entry && path.isAbsolute(entry)) candidates.add(path.join(entry, 'python3')); - } - for (const entry of ['/usr/local/bin/python3', '/usr/bin/python3', '/bin/python3']) { - candidates.add(entry); - } - for (const candidate of candidates) { - const resolved = validatedPathExecutable(candidate); - if (!resolved) continue; - let fd; - try { - fd = fs.openSync( - resolved, - fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_CLOEXEC ?? 0), - ); - } catch { - continue; +function linkNoReplace(sourcePath, destinationPath) { + try { + fs.linkSync(sourcePath, destinationPath); + } catch (error) { + // Callers treat "destination taken" as a distinct outcome, not a failure. + if (error?.code === 'EEXIST') return false; + if (!linkCreatedDespiteError(sourcePath)) { + // FAT, Coda, and some SMB/FUSE/virtiofs mounts have no hardlinks at all. + // Git falls back to rename here, but git can afford to lose collision + // detection because its objects are content-addressed; a plan destination + // is a plain name, so a replacing rename would silently clobber whatever + // is already there. Refuse loudly instead. + if (error?.code === 'EPERM' || error?.code === 'ENOTSUP' || error?.code === 'EMLINK') { + throw new Error( + `Generated-plan publication requires hard links, which this filesystem refused (${error.code}); refusing to fall back to a replacing rename`, + ); + } + throw error; } - const opened = fs.fstatSync(fd, { bigint: true }); - const executable = { fd, identity: statIdentity(opened), resolved }; - const version = spawnHeldExecutable( - executable, - ['-I', '-S', '-c', 'import sys; print(sys.version_info[0])'], - { - encoding: 'utf8', - env: { ...process.env, LANG: 'C', LC_ALL: 'C' }, - timeout: 10_000, - windowsHide: true, - }, - ); - if (version.status === 0 && version.stdout.trim() === '3') { - atomicMoverPath = executable; - return executable; - } - fs.closeSync(fd); } - throw new Error( - 'Safe generated-plan publication requires a trusted absolute Python 3 PATH candidate with libc renameat2 support', - ); -} - -function atomicMoveNoReplace(source, destination) { - const mover = resolveAtomicMover(); - const result = spawnHeldExecutable( - mover, - ['-I', '-S', '-c', RENAME_NOREPLACE_SCRIPT, source, destination], - { - encoding: 'utf8', - env: { ...process.env, LANG: 'C', LC_ALL: 'C' }, - timeout: 10_000, - windowsHide: true, - }, - ); - if (result.error) throw result.error; - if (result.status === 17) return false; - if (result.status !== 0) { - throw new Error( - `Atomic no-replace move failed (${result.status}): ${(result.stderr ?? '').trim()}`, - ); + try { + fs.unlinkSync(sourcePath); + } catch { + // The link succeeded, so the plan IS published. A temporary name left behind + // is a stray file, not an unpublished plan: reporting it as a failure would + // be a lie, and rolling back would unpublish a plan that is already live. } return true; } -function lstatOptional(absolute) { +// A directory holder is anything that owns a verified chain: a plan-parent +// handle, a ref's parent directory, or an absence guard. Two arrays describe it, +// both root-first and the same length — `chain` records each element's expected +// path and dev/ino/mode, and `descriptors` holds an open descriptor on each. +// +// Holding those descriptors is load-bearing rather than decorative. dev/ino/mode +// is unique only among *live* inodes: an inode number freed by an rmdir is handed +// straight back to the next mkdir, so a replacement directory can reproduce a +// recorded identity exactly. An open descriptor pins the inode, so the number +// cannot be recycled for as long as the holder exists. +function verifyPinnedDescriptors(holder) { + const { chain, descriptors } = holder; + if (!Array.isArray(descriptors) || descriptors.length !== chain.length) { + throw new Error('Generated-plan parent chain is missing the descriptors that pin it'); + } + chain.forEach((item, index) => { + const pinned = fs.fstatSync(descriptors[index], { bigint: true }); + if (!pinned.isDirectory() || stableDirectoryIdentity(pinned) !== item.identity) { + throw new Error('Generated-plan parent descriptor changed during the write'); + } + }); +} + +function verifyLexicalChain(holder) { + for (const item of holder.chain) { + let lexical; + try { + lexical = fs.lstatSync(item.expectedPath, { bigint: true }); + } catch (error) { + if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') throw error; + // A parent renamed out from under us is a mismatch, not a missing file: + // reporting the raw ENOENT would leak an unrelated-looking error out of a + // check whose whole job is to say the chain no longer holds. + throw new Error('Generated-plan lexical parent no longer matches its directory descriptor'); + } + if ( + lexical.isSymbolicLink() || + !lexical.isDirectory() || + stableDirectoryIdentity(lexical) !== item.identity + ) { + throw new Error('Generated-plan lexical parent no longer matches its directory descriptor'); + } + } +} + +// The whole platform seam, in five methods. Everything else an operation does is +// identical on both platforms and lives in the shared functions below. +// +// Only two things actually differ: how a name becomes a path, and what guard +// wraps the operation that uses it. +// +// Linux ANCHORS. /proc/self/fd// starts the walk at the inode the +// descriptor holds, so a parent renamed away cannot be traversed at all and the +// guard is a no-op — there is nothing left to verify. +// +// macOS VERIFIES. It resolves lexically, so before and after every operation it +// proves that each element of the path chain still names the exact inode being +// held for it. That DETECTS a swapped parent and aborts; it does not make the +// swap impossible. A swap landing inside the window is caught by the trailing +// check, after the fact, rather than being unreachable. The check runs after a +// failure too, because a verdict observed through a chain that has since changed +// is not a verdict. +const LINUX_ANCHORING = { + childPath(dirHandle, childName) { + return descriptorPath(dirHandle.fd, childName); + }, + verified(holders, run) { + return run(); + }, + descriptorMatchesChild(fd, expectedPath) { + return fs.realpathSync.native(descriptorPath(fd)) === expectedPath; + }, + parentStillResolves(parentHandle) { + return fs.realpathSync.native(descriptorPath(parentHandle.fd)) === parentHandle.expectedPath; + }, + verifyAbsentChild(guard) { + if (absentChildIsPresent(guard.ref)) { + throw new Error(`${guard.repoPath} appeared before evidence materialization completed`); + } + }, +}; + +const DARWIN_ANCHORING = { + childPath(dirHandle, childName) { + return path.join(dirHandle.expectedPath, childName); + }, + verified(holders, run) { + const list = Array.isArray(holders) ? holders : [holders]; + const proveChain = () => { + for (const holder of list) { + verifyPinnedDescriptors(holder); + verifyLexicalChain(holder); + } + }; + proveChain(); + let value; + try { + value = run(); + } catch (error) { + proveChain(); + throw error; + } + proveChain(); + return value; + }, + descriptorMatchesChild(fd, _expectedPath, childStat) { + // There is no live fd-to-path oracle on macOS (F_GETPATH is a name-cache + // snapshot, not an anchor), so escape is decided the other way round: the + // name was just resolved under a verified chain, and the descriptor opened + // from it counts only if it is that same inode. + const opened = fs.fstatSync(fd, { bigint: true }); + return ( + opened.isDirectory() && stableDirectoryIdentity(opened) === stableDirectoryIdentity(childStat) + ); + }, + parentStillResolves(parentHandle) { + // Both halves are needed: a directory renamed away keeps its inode, so the + // descriptors alone still match and only the lexical half notices it moved. + try { + verifyPinnedDescriptors(parentHandle); + verifyLexicalChain(parentHandle); + } catch { + return false; + } + return true; + }, + verifyAbsentChild(guard) { + let present; + try { + present = DARWIN_ANCHORING.verified(guard.handle, () => absentChildIsPresent(guard.ref)); + } catch (error) { + // A chain that no longer holds makes the absence verdict meaningless, and + // the caller reports that as the anchor changing rather than as a stray + // parent-descriptor error. Linux cannot reach this: its guard is a no-op. + throw new Error( + `Absence anchor changed for ${guard.repoPath}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + if (present) { + throw new Error(`${guard.repoPath} appeared before evidence materialization completed`); + } + }, +}; + +const ANCHORING_BACKENDS = new Map([ + ['linux', LINUX_ANCHORING], + ['darwin', DARWIN_ANCHORING], +]); + +function anchoringBackend() { + const backend = ANCHORING_BACKENDS.get(process.platform); + if (!backend) { + // requireDescriptorAnchoring normally refuses first; this is the same answer + // from the other side, so an unsupported platform can never fall through to + // whichever backend happened to be the ternary's default. + throw new Error( + `No generated-plan anchoring backend for ${process.platform}; refusing an unanchored write`, + ); + } + return backend; +} + +// Open, fstat, compare, close on mismatch. The descriptor never escapes this +// function unless it refers to the inode the caller already verified by name, so +// a lexical open that landed anywhere else cannot be used by accident. On Linux +// the comparison passes trivially — the /proc walk already resolved from the +// held parent — and costs one fstat to keep the guarantee structural rather than +// dependent on which backend is in play. +function adoptVerifiedFile(ref, expectedStat, flags) { + const fd = openVerifiedFile(ref.path, flags); + let opened; try { - return fs.lstatSync(absolute, { bigint: true }); + opened = fs.fstatSync(fd, { bigint: true }); + } catch (error) { + fs.closeSync(fd); + throw error; + } + if (stableFileIdentity(opened) !== stableFileIdentity(expectedStat)) { + fs.closeSync(fd); + return null; + } + return fd; +} + +function absentChildIsPresent(ref) { + try { + fs.lstatSync(ref.path, { bigint: true }); + } catch (error) { + if (error?.code === 'ENOENT') return false; + throw error; + } + return true; +} + +// The operations. Each is the same on both platforms; only the guard differs. +function lstatChild(ref) { + return anchoringBackend().verified(ref.dir, () => fs.lstatSync(ref.path, { bigint: true })); +} + +function openChildRead(ref, flags, expectedStat) { + return anchoringBackend().verified(ref.dir, () => { + const fd = adoptVerifiedFile(ref, expectedStat, flags); + if (fd === null) { + throw new Error(`${ref.name} was replaced between its verified stat and its no-follow open`); + } + return fd; + }); +} + +function createChild(ref, flags, mode) { + // O_CREAT|O_EXCL|O_NOFOLLOW is atomic at the leaf, so the only thing the guard + // has to cover is which directory the leaf landed in. + return anchoringBackend().verified(ref.dir, () => openVerifiedFile(ref.path, flags, mode)); +} + +function mkdirChild(ref, mode) { + anchoringBackend().verified(ref.dir, () => fs.mkdirSync(ref.path, { mode })); +} + +function publishNoReplace(sourceRef, destinationRef) { + return anchoringBackend().verified([sourceRef.dir, destinationRef.dir], () => + linkNoReplace(sourceRef.path, destinationRef.path), + ); +} + +// The single place a name becomes a path, and therefore the right place to +// enforce that a name is one ordinary component. +// +// A trailing separator is the sharp edge here, not a tidiness concern: +// open(path, O_NOFOLLOW) FOLLOWS a symlink when path ends in "/" — the trap +// behind CVE-2026-39822 / golang/go#79005, which let os.Root escape its own +// root. path.join preserves that trailing slash, so a component carrying one +// would turn every no-follow open in this file into a following one. +// normalizeRepoPath already rejects such components upstream; this is the +// chokepoint that makes it true for every caller, including the generated +// temporary and vault names that never pass through it. +function anchoredChild(dirHandle, childName) { + if ( + typeof childName !== 'string' || + childName === '' || + childName === '.' || + childName === '..' || + childName.includes('/') || + childName.includes('\\') || + childName.includes('\0') + ) { + throw new Error(`Refusing to resolve ${JSON.stringify(childName)} as a single path component`); + } + return { + dir: dirHandle, + name: childName, + path: anchoringBackend().childPath(dirHandle, childName), + }; +} + +function lstatAnchoredOptional(ref) { + try { + return lstatChild(ref); } catch (error) { if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return null; throw error; @@ -1063,39 +1381,37 @@ function openPlanParent( { createMissing = true, purpose = 'Generated-plan' } = {}, ) { requireDescriptorAnchoring(); - const flags = - fs.constants.O_RDONLY | - fs.constants.O_DIRECTORY | - fs.constants.O_NOFOLLOW | - (fs.constants.O_CLOEXEC ?? 0); + // Root-first and index-aligned with `chain`: verifyPinnedDescriptors relies on + // that, and the descriptors are what pin each recorded inode against reuse. const descriptors = []; try { - let currentFd = fs.openSync(repo, flags); + let currentFd = openVerifiedDirectory(repo, ANCHORED_DIRECTORY_FLAGS); descriptors.push(currentFd); const rootStat = fs.fstatSync(currentFd, { bigint: true }); const chain = [{ expectedPath: repo, identity: stableDirectoryIdentity(rootStat) }]; + let currentHandle = { fd: currentFd, expectedPath: repo, chain, descriptors }; const traversed = []; for (const component of parentComponents) { traversed.push(component); - const anchoredChild = descriptorPath(currentFd, component); + const child = anchoredChild(currentHandle, component); let childStat; let created = false; try { - childStat = fs.lstatSync(anchoredChild, { bigint: true }); + childStat = lstatChild(child); } catch (error) { if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') throw error; if (!createMissing) { throw new Error(`${purpose} parent does not exist: ${traversed.join('/')}`); } - fs.mkdirSync(anchoredChild, { mode: 0o755 }); - childStat = fs.lstatSync(anchoredChild, { bigint: true }); + mkdirChild(child, 0o755); + childStat = lstatChild(child); created = true; } if (childStat.isSymbolicLink() || !childStat.isDirectory()) { throw new Error(`${purpose} parent is not a real directory: ${traversed.join('/')}`); } const parentFd = currentFd; - const childFd = fs.openSync(anchoredChild, flags); + const childFd = openVerifiedDirectory(child.path, ANCHORED_DIRECTORY_FLAGS); descriptors.push(childFd); currentFd = childFd; if (created) { @@ -1103,18 +1419,16 @@ function openPlanParent( fs.fsyncSync(parentFd); } const expected = path.join(repo, ...traversed); - const actual = fs.realpathSync(descriptorPath(currentFd)); - if (actual !== expected) { + if (!anchoringBackend().descriptorMatchesChild(currentFd, expected, childStat)) { throw new Error(`${purpose} parent escaped the repository: ${traversed.join('/')}`); } const openedStat = fs.fstatSync(currentFd, { bigint: true }); chain.push({ expectedPath: expected, identity: stableDirectoryIdentity(openedStat) }); + currentHandle = { fd: currentFd, expectedPath: expected, chain, descriptors }; } - const stat = fs.fstatSync(currentFd, { bigint: true }); return { descriptors, fd: currentFd, - identity: stableDirectoryIdentity(stat), expectedPath: path.join(repo, ...parentComponents), chain, }; @@ -1134,9 +1448,16 @@ function closeDescriptors(descriptors) { } } +// A handle's identity IS its chain leaf's identity. Storing it twice meant two +// fstats a line apart and a re-stamp helper to keep them agreeing; deriving it +// removes both. +function handleIdentity(handle) { + return handle.chain[handle.chain.length - 1].identity; +} + function resolveGitDirectory(repo) { const result = git(repo, ['rev-parse', '--absolute-git-dir']); - return fs.realpathSync(decodeUtf8(result.stdout, 'Git administrative directory').trim()); + return fs.realpathSync.native(decodeUtf8(result.stdout, 'Git administrative directory').trim()); } function openBackupVault(repo, { createMissing = true } = {}) { @@ -1147,9 +1468,12 @@ function openBackupVault(repo, { createMissing = true } = {}) { }); fs.fchmodSync(handle.fd, 0o700); fs.fsyncSync(handle.fd); - const stat = fs.fstatSync(handle.fd, { bigint: true }); - handle.identity = stableDirectoryIdentity(stat); - handle.chain[handle.chain.length - 1].identity = handle.identity; + // mode is part of every directory identity, so hardening the vault changes the + // identity the chain recorded for it; without this the next verification would + // reject the directory it just hardened. + handle.chain[handle.chain.length - 1].identity = stableDirectoryIdentity( + fs.fstatSync(handle.fd, { bigint: true }), + ); return { ...handle, gitDirectory }; } @@ -1157,33 +1481,28 @@ function validatePlanParent(parentHandle) { const descriptorStat = fs.fstatSync(parentHandle.fd, { bigint: true }); if ( !descriptorStat.isDirectory() || - stableDirectoryIdentity(descriptorStat) !== parentHandle.identity + stableDirectoryIdentity(descriptorStat) !== handleIdentity(parentHandle) ) { throw new Error('Generated-plan parent descriptor changed during the write'); } - const descriptorRealPath = fs.realpathSync(descriptorPath(parentHandle.fd)); - if (descriptorRealPath !== parentHandle.expectedPath) { + if (!anchoringBackend().parentStillResolves(parentHandle)) { throw new Error('Generated-plan parent moved or was replaced during the write'); } - for (const item of parentHandle.chain) { - const lexicalStat = fs.lstatSync(item.expectedPath, { bigint: true }); - if ( - lexicalStat.isSymbolicLink() || - !lexicalStat.isDirectory() || - stableDirectoryIdentity(lexicalStat) !== item.identity - ) { - throw new Error('Generated-plan lexical parent no longer matches its directory descriptor'); - } - } + // Both halves come from the shared helpers rather than being restated here: an + // earlier hand-copy of the lexical loop lost verifyLexicalChain's ENOENT/ENOTDIR + // translation, so a renamed parent could surface a raw errno from a function + // with a dozen call sites. + verifyPinnedDescriptors(parentHandle); + verifyLexicalChain(parentHandle); } function inspectPlanDestination( - finalPath, + finalRef, { replace, expectedIdentity, mustBeAbsent = false } = {}, ) { let stat; try { - stat = fs.lstatSync(finalPath, { bigint: true }); + stat = lstatChild(finalRef); } catch (error) { if (error?.code === 'ENOENT') { if (expectedIdentity) throw new Error('Generated plan disappeared during the write'); @@ -1201,19 +1520,17 @@ function inspectPlanDestination( if (expectedIdentity && identity !== expectedIdentity) { throw new Error('Generated plan changed during the write'); } - return identity; + return stat; } -function openExistingPlanDestination(finalPath, replace) { - const identity = inspectPlanDestination(finalPath, { replace }); - if (identity === null) { +function openExistingPlanDestination(finalRef, replace) { + const stat = inspectPlanDestination(finalRef, { replace }); + if (stat === null) { if (replace) throw new Error('Deepen mode requires an existing generated plan to replace'); return { fd: undefined, identity: null, stableIdentity: null }; } - const fd = fs.openSync( - finalPath, - fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_CLOEXEC ?? 0), - ); + const identity = statIdentity(stat); + const fd = openChildRead(finalRef, VERIFIED_READ_FLAGS, stat); try { const opened = fs.fstatSync(fd, { bigint: true }); if (!opened.isFile() || statIdentity(opened) !== identity) { @@ -1264,8 +1581,8 @@ function hashOpenFile(fd, label) { }; } -function validateCommittedPlan(finalPath, tempFd, expectedTemp, testHooks) { - const before = fs.lstatSync(finalPath, { bigint: true }); +function validateCommittedPlan(finalRef, tempFd, expectedTemp, testHooks) { + const before = lstatChild(finalRef); if ( before.isSymbolicLink() || !before.isFile() || @@ -1273,19 +1590,16 @@ function validateCommittedPlan(finalPath, tempFd, expectedTemp, testHooks) { ) { throw new Error('Generated-plan destination failed its first post-write identity check'); } - const finalFd = fs.openSync( - finalPath, - fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_CLOEXEC ?? 0), - ); + const finalFd = openChildRead(finalRef, VERIFIED_READ_FLAGS, before); try { const opened = fs.fstatSync(finalFd, { bigint: true }); if (!opened.isFile() || stableFileIdentity(opened) !== expectedTemp.identity) { throw new Error('Generated-plan destination changed while its no-follow descriptor opened'); } - testHooks?.afterFinalOpen?.({ fd: finalFd, finalPath }); + testHooks?.afterFinalOpen?.({ fd: finalFd, finalPath: finalRef.path }); const committedViaTemp = hashOpenFile(tempFd, 'generated-plan committed file'); const committedViaPath = hashOpenFile(finalFd, 'generated-plan destination descriptor'); - const after = fs.lstatSync(finalPath, { bigint: true }); + const after = lstatChild(finalRef); const openedAfter = fs.fstatSync(finalFd, { bigint: true }); if ( after.isSymbolicLink() || @@ -1320,22 +1634,19 @@ function copyOpenFile(sourceFd, destinationFd, label) { return after; } -function openVerifiedPathFile(absolute, label) { - const before = fs.lstatSync(absolute, { bigint: true }); +function openVerifiedAnchoredFile(ref, label, knownStat) { + const before = knownStat ?? lstatChild(ref); if (before.isSymbolicLink() || !before.isFile()) { throw new Error(`${label} is not a regular no-follow file`); } - const fd = fs.openSync( - absolute, - fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_CLOEXEC ?? 0), - ); + const fd = openChildRead(ref, VERIFIED_READ_FLAGS, before); try { const opened = fs.fstatSync(fd, { bigint: true }); if (!opened.isFile() || stableFileIdentity(opened) !== stableFileIdentity(before)) { throw new Error(`${label} changed while its descriptor opened`); } const layer = hashOpenFile(fd, label); - const after = fs.lstatSync(absolute, { bigint: true }); + const after = lstatChild(ref); if (after.isSymbolicLink() || !after.isFile() || stableFileIdentity(after) !== layer.identity) { throw new Error(`${label} changed after verification`); } @@ -1358,10 +1669,10 @@ export function readPlanSafely({ repo: repoInput, generatedPlanPath, testHooks } let fd; try { validatePlanParent(parentHandle); - const finalPath = descriptorPath(parentHandle.fd, finalName); + const finalRef = anchoredChild(parentHandle, finalName); let before; try { - before = fs.lstatSync(finalPath, { bigint: true }); + before = lstatChild(finalRef); } catch (error) { if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') { throw new Error(`Loaded plan does not exist: ${generatedPlan}`); @@ -1371,15 +1682,12 @@ export function readPlanSafely({ repo: repoInput, generatedPlanPath, testHooks } if (before.isSymbolicLink() || !before.isFile()) { throw new Error('Loaded plan must be a regular file, never a symlink'); } - fd = fs.openSync( - finalPath, - fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_CLOEXEC ?? 0), - ); + fd = openChildRead(finalRef, VERIFIED_READ_FLAGS, before); const opened = fs.fstatSync(fd, { bigint: true }); if (!opened.isFile() || statIdentity(opened) !== statIdentity(before)) { throw new Error('Loaded plan changed while its no-follow descriptor opened'); } - testHooks?.afterPlanOpen?.({ fd, finalPath }); + testHooks?.afterPlanOpen?.({ fd, finalPath: finalRef.path }); const chunks = []; let total = 0; const buffer = Buffer.allocUnsafe(64 * 1024); @@ -1394,7 +1702,7 @@ export function readPlanSafely({ repo: repoInput, generatedPlanPath, testHooks } decodeUtf8(contents, 'loaded plan'); const after = fs.fstatSync(fd, { bigint: true }); assertStableIdentity(opened, after, 'loaded plan'); - const pathAfter = fs.lstatSync(finalPath, { bigint: true }); + const pathAfter = lstatChild(finalRef); if ( pathAfter.isSymbolicLink() || !pathAfter.isFile() || @@ -1419,24 +1727,22 @@ function artifactGitPath(name) { return `gitnexus-plan-backups/${name}`; } -function verifyVaultArtifactFromFreshRoot(repo, gitPath, expectedLayer) { - const components = gitPath.split('/'); - if (components.length !== 2 || components[0] !== 'gitnexus-plan-backups') { - throw new Error(`Invalid Git-admin artifact path: ${gitPath}`); - } +function verifyVaultArtifactFromFreshRoot(repo, name, expectedLayer) { const freshVault = openBackupVault(repo, { createMissing: false }); try { validatePlanParent(freshVault); - const opened = openVerifiedPathFile( - descriptorPath(freshVault.fd, components[1]), - `Git-admin artifact ${gitPath}`, + const opened = openVerifiedAnchoredFile( + anchoredChild(freshVault, name), + `Git-admin artifact ${artifactGitPath(name)}`, ); try { if ( opened.layer.identity !== expectedLayer.identity || opened.layer.digest !== expectedLayer.digest ) { - throw new Error(`Git-admin artifact changed before fresh-root verification: ${gitPath}`); + throw new Error( + `Git-admin artifact changed before fresh-root verification: ${artifactGitPath(name)}`, + ); } } finally { fs.closeSync(opened.fd); @@ -1449,16 +1755,8 @@ function verifyVaultArtifactFromFreshRoot(repo, gitPath, expectedLayer) { function createVaultCopyFromFd(repo, vault, sourceFd, role) { validatePlanParent(vault); const name = `.gitnexus-plan-${role}-${process.pid}-${randomBytes(16).toString('hex')}.bak`; - const absolute = descriptorPath(vault.fd, name); - const destinationFd = fs.openSync( - absolute, - fs.constants.O_RDWR | - fs.constants.O_CREAT | - fs.constants.O_EXCL | - fs.constants.O_NOFOLLOW | - (fs.constants.O_CLOEXEC ?? 0), - 0o600, - ); + const artifact = anchoredChild(vault, name); + const destinationFd = createChild(artifact, VERIFIED_CREATE_FLAGS, 0o600); let destination; try { const sourceStat = copyOpenFile(sourceFd, destinationFd, role); @@ -1469,7 +1767,7 @@ function createVaultCopyFromFd(repo, vault, sourceFd, role) { if (source.size !== destination.size || source.digest !== destination.digest) { throw new Error(`${role} vault copy does not match its held source descriptor`); } - const pathStat = fs.lstatSync(absolute, { bigint: true }); + const pathStat = lstatChild(artifact); if ( pathStat.isSymbolicLink() || !pathStat.isFile() || @@ -1481,24 +1779,15 @@ function createVaultCopyFromFd(repo, vault, sourceFd, role) { } finally { fs.closeSync(destinationFd); } - const gitPath = artifactGitPath(name); - verifyVaultArtifactFromFreshRoot(repo, gitPath, destination); - return { role, gitPath, layer: destination }; + verifyVaultArtifactFromFreshRoot(repo, name, destination); + return { role, gitPath: artifactGitPath(name), layer: destination }; } function createVaultCopyFromBytes(repo, vault, contents, role) { validatePlanParent(vault); const name = `.gitnexus-plan-${role}-${process.pid}-${randomBytes(16).toString('hex')}.bak`; - const absolute = descriptorPath(vault.fd, name); - const fd = fs.openSync( - absolute, - fs.constants.O_RDWR | - fs.constants.O_CREAT | - fs.constants.O_EXCL | - fs.constants.O_NOFOLLOW | - (fs.constants.O_CLOEXEC ?? 0), - 0o600, - ); + const artifact = anchoredChild(vault, name); + const fd = createChild(artifact, VERIFIED_CREATE_FLAGS, 0o600); let layer; try { writeAll(fd, contents); @@ -1508,7 +1797,7 @@ function createVaultCopyFromBytes(repo, vault, contents, role) { if (layer.size !== BigInt(contents.length) || layer.digest !== sha256(contents)) { throw new Error(`${role} vault copy does not match the intended plan bytes`); } - const pathStat = fs.lstatSync(absolute, { bigint: true }); + const pathStat = lstatChild(artifact); if ( pathStat.isSymbolicLink() || !pathStat.isFile() || @@ -1520,32 +1809,31 @@ function createVaultCopyFromBytes(repo, vault, contents, role) { } finally { fs.closeSync(fd); } - const gitPath = artifactGitPath(name); - verifyVaultArtifactFromFreshRoot(repo, gitPath, layer); - return { role, gitPath, layer }; + verifyVaultArtifactFromFreshRoot(repo, name, layer); + return { role, gitPath: artifactGitPath(name), layer }; } function movePathToVault(repo, sourceHandle, sourceName, vault, role) { - const source = descriptorPath(sourceHandle.fd, sourceName); - if (!lstatOptional(source)) return null; + const source = anchoredChild(sourceHandle, sourceName); + if (!lstatAnchoredOptional(source)) return null; const name = `.gitnexus-plan-${role}-${process.pid}-${randomBytes(16).toString('hex')}.bak`; - const destination = descriptorPath(vault.fd, name); - const moved = atomicMoveNoReplace( - externalDescriptorPath(sourceHandle.fd, sourceName), - externalDescriptorPath(vault.fd, name), - ); + const destination = anchoredChild(vault, name); + const moved = publishNoReplace(source, destination); if (!moved) throw new Error(`${role} preservation destination unexpectedly exists`); fs.fsyncSync(sourceHandle.fd); if (vault.fd !== sourceHandle.fd) fs.fsyncSync(vault.fd); - const sourceAfter = lstatOptional(source); - const destinationAfter = lstatOptional(destination); + const sourceAfter = lstatAnchoredOptional(source); + const destinationAfter = lstatAnchoredOptional(destination); if (sourceAfter || !destinationAfter) { throw new Error(`${role} could not be atomically moved into the Git-admin vault`); } - const opened = openVerifiedPathFile(destination, `${role} Git-admin artifact`); - const gitPath = artifactGitPath(name); - verifyVaultArtifactFromFreshRoot(repo, gitPath, opened.layer); - return { role, gitPath, layer: opened.layer, fd: opened.fd }; + const opened = openVerifiedAnchoredFile( + destination, + `${role} Git-admin artifact`, + destinationAfter, + ); + verifyVaultArtifactFromFreshRoot(repo, name, opened.layer); + return { role, gitPath: artifactGitPath(name), layer: opened.layer, fd: opened.fd }; } function formatPreservedArtifacts(artifacts) { @@ -1600,10 +1888,10 @@ export function writePlanSafely({ const finalName = components.pop(); let parentHandle; let vaultHandle; - let tempPath; + let tempRef; let tempName; let tempFd; - let finalPath; + let finalRef; let expectedTemp; let originalDestination; let priorBackup; @@ -1611,7 +1899,6 @@ export function writePlanSafely({ try { parentHandle = openPlanParent(repo, components); vaultHandle = openBackupVault(repo); - resolveAtomicMover(); const parentDevice = fs.fstatSync(parentHandle.fd, { bigint: true }).dev; const vaultDevice = fs.fstatSync(vaultHandle.fd, { bigint: true }).dev; if (parentDevice !== vaultDevice) { @@ -1622,19 +1909,11 @@ export function writePlanSafely({ testHooks?.afterParentOpen?.({ fd: parentHandle.fd, path: parentHandle.expectedPath }); validatePlanParent(parentHandle); validatePlanParent(vaultHandle); - finalPath = descriptorPath(parentHandle.fd, finalName); - originalDestination = openExistingPlanDestination(finalPath, shouldReplace); + finalRef = anchoredChild(parentHandle, finalName); + originalDestination = openExistingPlanDestination(finalRef, shouldReplace); tempName = `.gitnexus-plan-${process.pid}-${randomBytes(16).toString('hex')}.tmp`; - tempPath = descriptorPath(parentHandle.fd, tempName); - tempFd = fs.openSync( - tempPath, - fs.constants.O_RDWR | - fs.constants.O_CREAT | - fs.constants.O_EXCL | - fs.constants.O_NOFOLLOW | - (fs.constants.O_CLOEXEC ?? 0), - 0o600, - ); + tempRef = anchoredChild(parentHandle, tempName); + tempFd = createChild(tempRef, VERIFIED_CREATE_FLAGS, 0o600); writeAll(tempFd, contents); fs.fchmodSync(tempFd, 0o644); fs.fsyncSync(tempFd); @@ -1646,12 +1925,12 @@ export function writePlanSafely({ testHooks?.beforeRename?.({ fd: parentHandle.fd, path: parentHandle.expectedPath, - tempPath, + tempPath: tempRef.path, }); validatePlanParent(parentHandle); validatePlanParent(vaultHandle); validateOpenPlanDestination(originalDestination); - const tempPathStat = fs.lstatSync(tempPath, { bigint: true }); + const tempPathStat = lstatChild(tempRef); const currentTemp = hashOpenFile(tempFd, 'generated-plan temporary file'); if ( tempPathStat.isSymbolicLink() || @@ -1664,7 +1943,7 @@ export function writePlanSafely({ } if (shouldReplace) { - testHooks?.beforeBackupMove?.({ fd: parentHandle.fd, finalPath }); + testHooks?.beforeBackupMove?.({ fd: parentHandle.fd, finalPath: finalRef.path }); const originalLayer = hashOpenFile(originalDestination.fd, 'prior generated plan'); if (originalLayer.digest !== expectedDigest) { throw new Error( @@ -1673,7 +1952,7 @@ export function writePlanSafely({ } validatePlanParent(parentHandle); validateOpenPlanDestination(originalDestination); - inspectPlanDestination(finalPath, { + inspectPlanDestination(finalRef, { replace: true, expectedIdentity: originalDestination.identity, }); @@ -1691,20 +1970,20 @@ export function writePlanSafely({ ); throw new Error('Destination raced while the prior plan was moved into preservation'); } - if (lstatOptional(finalPath)) { + if (lstatAnchoredOptional(finalRef)) { throw new Error('Destination reappeared after the prior plan was preserved'); } } testHooks?.beforePublication?.({ fd: parentHandle.fd, - finalPath, - tempPath, + finalPath: finalRef.path, + tempPath: tempRef.path, replace: shouldReplace, }); validatePlanParent(parentHandle); validatePlanParent(vaultHandle); - const finalTempPathStat = fs.lstatSync(tempPath, { bigint: true }); + const finalTempPathStat = lstatChild(tempRef); const finalTemp = hashOpenFile(tempFd, 'generated-plan temporary file'); if ( finalTempPathStat.isSymbolicLink() || @@ -1715,19 +1994,25 @@ export function writePlanSafely({ ) { throw new Error('Generated-plan temporary path or content changed at publication'); } - atomicMoveNoReplace( - externalDescriptorPath(parentHandle.fd, tempName), - externalDescriptorPath(parentHandle.fd, finalName), - ); - if (lstatOptional(tempPath) || !lstatOptional(finalPath)) { + // link() reports the race itself; re-deriving that verdict from a later pair + // of stats would be both slower and weaker. + if (!publishNoReplace(tempRef, finalRef)) { throw new Error('Generated-plan publication was refused because the destination raced'); } + // link() creates a directory entry, so it needs the parent fsync that rename + // needed: the file's own bytes were fsynced through tempFd before this point, + // and this makes the name that now reaches them durable too. Skipping it is + // the step write-file-atomic omits and maildir, git and atomicwrites all + // mandate. + // + // Honest limitation: on macOS fsync is not a write barrier — the durable + // primitive there is fcntl(F_FULLFSYNC), which Node does not expose. A + // macOS plan write is therefore as durable as fsync makes it and no more. fs.fsyncSync(parentHandle.fd); - testHooks?.afterPublication?.({ fd: parentHandle.fd, finalPath }); - testHooks?.afterRename?.({ fd: parentHandle.fd, finalPath }); + testHooks?.afterPublication?.({ fd: parentHandle.fd, finalPath: finalRef.path }); validatePlanParent(parentHandle); validatePlanParent(vaultHandle); - validateCommittedPlan(finalPath, tempFd, expectedTemp, testHooks); + validateCommittedPlan(finalRef, tempFd, expectedTemp, testHooks); const receipt = { generated_plan_path: generatedPlan, bytes_written: contents.length }; if (priorBackup) receipt.prior_plan_backup_git_path = priorBackup.gitPath; return receipt; @@ -1848,6 +2133,11 @@ export function snapshotEvidence({ const headGuards = captureHeadGuards(repo); const dirty = initialDirty.records; const mutationGuards = []; + // Per-snapshot walk state: `absenceCache` owns every descriptor an absence + // anchor holds, deduplicated by repo-relative prefix and closed exactly once + // below; `guardedDirectories` keeps parent guarding to one stat per directory. + const absenceCache = new Map(); + const walkState = { absenceCache, guardedDirectories: new Set() }; try { testHooks?.afterAnchorCapture?.({ headCommit: head }); @@ -1862,7 +2152,9 @@ export function snapshotEvidence({ testHooks?.afterGitLayerLoad?.({ headCommit: head }); const globalEntries = [...dirty.values()] .filter((record) => record.path !== generatedPlan) - .map((record) => materializeRecord(repo, record, layers, mutationGuards, testHooks)); + .map((record) => + materializeRecord(repo, record, layers, mutationGuards, testHooks, walkState), + ); const citedEntries = [...normalizedCitations].sort(compareUtf8).map((repoPath) => { const status = dirty.get(repoPath) ?? { path: repoPath, @@ -1871,7 +2163,7 @@ export function snapshotEvidence({ rename_to: null, has_untracked: false, }; - const entry = materializeRecord(repo, status, layers, mutationGuards, testHooks); + const entry = materializeRecord(repo, status, layers, mutationGuards, testHooks, walkState); const present = Object.values(entry.object_kind).some((kind) => kind !== ABSENT); if (!present) entry.state = ABSENT; else if (entry.state === 'clean' && entry.object_kind.untracked !== ABSENT) { @@ -1906,21 +2198,13 @@ export function snapshotEvidence({ throw new Error(`${guard.absolute} changed before evidence materialization completed`); } } else if (guard.type === 'absence') { + // statIdentity is a strict superset of stableDirectoryIdentity on the + // same stat, so comparing both could only ever fire together. const parent = fs.fstatSync(guard.fd, { bigint: true }); - if ( - !parent.isDirectory() || - stableDirectoryIdentity(parent) !== guard.parentIdentity || - statIdentity(parent) !== guard.parentMutationIdentity - ) { + if (!parent.isDirectory() || statIdentity(parent) !== guard.parentMutationIdentity) { throw new Error(`Absence anchor changed for ${guard.repoPath}`); } - try { - fs.lstatSync(descriptorPath(guard.fd, guard.childName), { bigint: true }); - } catch (error) { - if (error?.code === 'ENOENT') continue; - throw error; - } - throw new Error(`${guard.repoPath} appeared before evidence materialization completed`); + anchoringBackend().verifyAbsentChild(guard); } } for (const guard of headGuards) verifyControlFile(guard); @@ -1955,12 +2239,10 @@ export function snapshotEvidence({ cited_path_manifest: citedEntries, }; } finally { - const closed = new Set(); - for (const guard of mutationGuards) { - if (guard.type !== 'absence' || closed.has(guard.fd)) continue; - closed.add(guard.fd); + // One entry per distinct anchored directory, so one close per descriptor. + for (const handle of absenceCache.values()) { try { - fs.closeSync(guard.fd); + fs.closeSync(handle.fd); } catch { // Preserve the primary snapshot result/error. } diff --git a/.claude/skills/gitnexus-refactoring/SKILL.md b/.claude/skills/gitnexus-refactoring/SKILL.md index 2dbb71ca0..9d63eb6e3 100644 --- a/.claude/skills/gitnexus-refactoring/SKILL.md +++ b/.claude/skills/gitnexus-refactoring/SKILL.md @@ -13,9 +13,32 @@ description: "Use when the user wants to rename, extract, split, move, or restru - "Move this to a new file" - Any task involving renaming, extracting, splitting, or restructuring code +## Bind the repository first + +Refactoring writes to disk. `rename` with `dry_run: false` edits files in +whichever repository was resolved, so binding identity here is a safety gate, +not bookkeeping. + +Call `list_repos {}` before the first tool call. With one indexed repository, +use the examples below as written. With more than one, pass `repo` on every +call: an omitted `repo` normally errors, but under an MCP policy with a +configured default it resolves to that default silently. If you cannot tell +which repository is meant, stop and ask. Never run `rename` with +`dry_run: false` until the preview in the same bound repository has been +reviewed — its returned `file_path` values show which checkout is about to be +written, so read them as a confirmation of identity. + +`list_repos` is paginated, so page with `offset: pagination.nextOffset` until +`hasMore` is false before concluding a repository is absent. + +`detect_changes` takes `worktree` when you are editing a linked worktree the +MCP server was not launched from; otherwise `git diff` runs in the wrong +checkout and reports nothing changed, which reads as a verified refactor. + ## Workflow ``` +0. list_repos {} → Bind repo (and worktree) 1. impact({target: "X", direction: "upstream"}) → Map all dependents 2. query({search_query: "X"}) → Find execution flows involving X 3. context({name: "X"}) → See all incoming/outgoing refs @@ -29,7 +52,9 @@ description: "Use when the user wants to rename, extract, split, move, or restru ### Rename Symbol ``` +- [ ] list_repos {} — bind repo; explicit repo when >1 indexed, ask if ambiguous - [ ] rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits +- [ ] Confirm the previewed file paths are in the bound repository/worktree - [ ] Review graph edits (high confidence) and text_search edits (review carefully) - [ ] If satisfied: rename({..., dry_run: false}) — apply edits - [ ] detect_changes() — verify only expected files changed @@ -39,6 +64,7 @@ description: "Use when the user wants to rename, extract, split, move, or restru ### Extract Module ``` +- [ ] list_repos {} — bind repo; explicit repo when >1 indexed, ask if ambiguous - [ ] context({name: target}) — see all incoming/outgoing refs - [ ] impact({target, direction: "upstream"}) — find all external callers - [ ] Define new module interface @@ -50,6 +76,7 @@ description: "Use when the user wants to rename, extract, split, move, or restru ### Split Function/Service ``` +- [ ] list_repos {} — bind repo; explicit repo when >1 indexed, ask if ambiguous - [ ] context({name: target}) — understand all callees - [ ] Group callees by responsibility - [ ] impact({target, direction: "upstream"}) — map callers to update @@ -64,7 +91,7 @@ description: "Use when the user wants to rename, extract, split, move, or restru **rename** — automated multi-file rename: ``` -rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true}) +rename({symbol_name: "validateUser", new_name: "authenticateUser", repo: "my-app", dry_run: true}) → 12 edits across 8 files → 10 graph edits (high confidence), 2 text_search edits (review) → Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}] @@ -73,7 +100,7 @@ rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true **impact** — map all dependents first: ``` -impact({target: "validateUser", direction: "upstream"}) +impact({target: "validateUser", repo: "my-app", direction: "upstream"}) → d=1: loginHandler, apiMiddleware, testUtils → Affected Processes: LoginFlow, TokenRefresh ``` @@ -87,6 +114,14 @@ detect_changes({scope: "all"}) → Risk: MEDIUM ``` +`partial: true` (a graph query failed) or `truncated: true` (the changed-symbol +listing was capped) means the result is short of the truth: a short or empty +list is not proof that only the expected files changed. Re-run it rather than +treat the refactor as verified. + +A wrong-worktree zero carries neither flag and is indistinguishable from a +clean verification, so confirm the diffed checkout is the one you edited. + **cypher** — custom reference queries: ```cypher @@ -102,20 +137,28 @@ RETURN caller.name, caller.filePath ORDER BY caller.filePath | Cross-area refs | Use detect_changes after to verify scope | | String/dynamic refs | query to find them | | External/public API | Version and deprecate properly | +| Same name in another indexed repo | Bind `repo`; verify previewed paths before applying | ## Example: Rename `validateUser` to `authenticateUser` ``` -1. rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true}) +0. list_repos {} + → total: 2 (my-app, billing-api) — both define validateUser, so bind explicitly + +1. rename({symbol_name: "validateUser", new_name: "authenticateUser", repo: "my-app", dry_run: true}) → 12 edits: 10 graph (safe), 2 text_search (review) → Files: validator.ts, login.ts, middleware.ts, config.json... 2. Review text_search edits (config.json: dynamic reference!) -3. rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false}) +3. rename({symbol_name: "validateUser", new_name: "authenticateUser", repo: "my-app", dry_run: false}) → Applied 12 edits across 8 files -4. detect_changes({scope: "all"}) +4. detect_changes({scope: "all", repo: "my-app"}) → Affected: LoginFlow, TokenRefresh → Risk: MEDIUM — run tests for these flows + Repository: my-app (/abs/path/my-app) Worktree: same Index: current ``` + +With a single indexed repository, step 0 returns `total: 1` and the `repo` +argument drops out of every call above. diff --git a/.claude/skills/gitnexus-work/SKILL.md b/.claude/skills/gitnexus-work/SKILL.md index 4f7856ea7..f9baab16a 100644 --- a/.claude/skills/gitnexus-work/SKILL.md +++ b/.claude/skills/gitnexus-work/SKILL.md @@ -216,7 +216,10 @@ Work through plan §7 step by step, in order. For each step: `detect_changes` → commit as one unbroken sequence from the repository root — interleaving other work between the gate and the commit is how the gate gets skipped. Unexpected - affected flows → investigate before committing, not after. + affected flows → investigate before committing, not after. A result + flagged `partial` (a graph query failed) or `truncated` (the symbol + listing was capped) blocks the commit the same way: the gate did not + see every changed symbol, so re-run it rather than read it as clean. A relationship-affecting implementation edit or commit invalidates the procedure's prior proof. The next step must perform the required inter-step diff --git a/.claude/skills/gitnexus-work/references/evidence-provenance.md b/.claude/skills/gitnexus-work/references/evidence-provenance.md index c686599da..3df5a046d 100644 --- a/.claude/skills/gitnexus-work/references/evidence-provenance.md +++ b/.claude/skills/gitnexus-work/references/evidence-provenance.md @@ -98,8 +98,11 @@ excluded. ## Safe existing-plan read contract -`read-plan` fails closed unless Linux `/proc/self/fd`, `O_DIRECTORY`, and -`O_NOFOLLOW` are available. It resolves the exact Git top-level, opens the +`read-plan` fails closed unless the host platform can resolve names against a +held directory descriptor: Linux `/proc/self/fd` with `O_DIRECTORY` and +`O_NOFOLLOW`, or macOS `O_DIRECTORY`/`O_NOFOLLOW`. Every other platform is +refused outright — an unverified read is not a degraded read, it is a different, +racy operation. It resolves the exact Git top-level, opens the repository root and every plan parent as held no-follow directory descriptors, rejects missing, symlink, non-directory, and escaping parents, and opens the leaf with `O_NOFOLLOW`. It reads at most 16 MiB from that held file descriptor, @@ -109,13 +112,17 @@ Neither Deepen nor work may parse bytes obtained before or outside this receipt. ## Safe generated-plan write contract -The writer fails closed unless Linux `/proc/self/fd`, `O_DIRECTORY`, -`O_NOFOLLOW`, and Python 3 with libc `renameat2(RENAME_NOREPLACE)` support are -available. Python may live in `/usr/local`, a Nix profile, or another absolute -PATH directory, but the helper accepts only a resolved executable and -containing directory owned by root or the current user and not writable by -group/other. The resolved executable is opened without following links and -invoked through that held descriptor. Relative PATH entries are ignored. The plan parent and the +The writer fails closed unless the host platform offers `O_DIRECTORY` and +`O_NOFOLLOW`, plus `/proc/self/fd` on Linux. It spawns no interpreter and loads +no native code: publication is `link(2)`, which is atomic, fails `EEXIST` when +the destination name is taken, and refuses a symlinked destination without +following it — the same no-replace guarantee `renameat2(RENAME_NOREPLACE)` and +`renameatx_np(RENAME_EXCL)` provide, available through `fs.linkSync` on every +supported platform. The temporary name is unlinked once the link succeeds; the +published file is the same inode the writer created and verified, so every +identity check downstream holds by construction. A link that succeeds followed +by an unlink that fails leaves the plan published and is reported as success, +because it is one. The plan parent and the repository's Git-admin directory must also share a filesystem. It resolves the target repository's exact Git top-level, opens that root and every destination parent as held no-follow directory descriptors, creates missing @@ -128,15 +135,45 @@ The writer creates a random exclusive temporary file relative to the held final parent descriptor and keeps its no-follow descriptor open. It writes and flushes the bytes, binds the temporary name to the opened inode, and hashes the open file before publication. Immediately before publication it revalidates -the parent and the temporary path, inode, size, and digest. Publication uses an -atomic no-replace move relative to the held directory descriptor. Initial mode -therefore cannot overwrite a destination that appears after the absent check. +the parent and the temporary path, inode, size, and digest. Publication links +the temporary name to the destination relative to the held directory +descriptor, which fails rather than replaces if the destination is taken. +Initial mode therefore cannot overwrite a destination that appears after the +absent check. The writer then flushes the directory and revalidates the committed path by opening it with `O_NOFOLLOW`, hashing both the original temporary fd and the path-bound fd, and performing a second descriptor-anchored path identity check after hashing. A detected mutation or replacement aborts instead of accepting mixed-era output. +### Linux anchors, macOS verifies + +The two platforms reach the same destination by different proofs, and the +difference is real enough to state rather than smooth over. + +On Linux every name resolves through `/proc/self/fd//`, a magic link +the kernel resolves against the inode the descriptor already holds. The names +above it are never re-walked, so an attacker who renames a parent between the +check and the use cannot redirect the operation. The race is impossible, not +merely detected. + +macOS has no such path. `/dev/fd/` is a devfs node, not a magic link: it can +be opened, but nothing can be resolved through it. `open("/dev/fd//child")` +returns `ENOENT`, and `realpath` of it returns `/dev/fd/` rather than the +directory's path — measured on macOS 26, not inferred. Node exposes no `openat`, +no `dir_fd` parameter, and no FFI, so on macOS the writer resolves names +lexically with `O_NOFOLLOW` at every component, holds an open descriptor on +every directory in the chain for the whole operation, and proves before *and* +after each step that the chain still names exactly the inodes it is holding. +Holding the descriptors is what makes the recorded inode numbers trustworthy: +an open descriptor pins its inode, so a freed number cannot be recycled beneath +the walk. + +What that buys is detection rather than prevention. A parent swapped inside the +window between a check and its use is caught by the check that follows, and the +operation aborts having written nothing — but on Linux it could not have +happened at all. No published byte escapes verification on either platform. + `--replace` accepts only a pre-existing regular file and is reserved for Deepen; without it, accidental overwrite is rejected. It also requires the exact canonical `generated_plan_path` and `plan_digest` from the same session's diff --git a/.claude/skills/gitnexus-work/scripts/evidence-provenance.mjs b/.claude/skills/gitnexus-work/scripts/evidence-provenance.mjs index 181d2120b..793fe4cd8 100644 --- a/.claude/skills/gitnexus-work/scripts/evidence-provenance.mjs +++ b/.claude/skills/gitnexus-work/scripts/evidence-provenance.mjs @@ -479,11 +479,11 @@ function resolveOwnGitTopLevel(absolute) { if (result.status !== 0) return null; let topLevel; try { - topLevel = fs.realpathSync(decodeUtf8(result.stdout, 'nested repository root').trim()); + topLevel = fs.realpathSync.native(decodeUtf8(result.stdout, 'nested repository root').trim()); } catch { return null; } - return topLevel === fs.realpathSync(absolute) ? topLevel : null; + return topLevel === fs.realpathSync.native(absolute) ? topLevel : null; } function readOwnGitlinkHead(absolute) { @@ -616,17 +616,30 @@ function filesystemObject(absolute, expectedKind, mutationGuards, testHooks) { throw new Error(`Unsupported filesystem object at ${absolute}`); } -function guardPathParents(repo, repoPath, mutationGuards) { +// Every dirty path re-walks its own parents, and dirty paths overwhelmingly +// share them — the repository root is re-stat'ed once per path. `guarded` is +// per-snapshot and remembers which absolute directories already carry a guard, +// so each distinct directory is stat'ed and guarded exactly once. +// +// Keeping the first-seen identity is the conservative choice: verifyGuards +// re-checks every guard against the filesystem at the end, so a directory that +// changes after it was guarded still fails there. Skipping a re-stat cannot hide +// a change; it only avoids recording the same directory twice. +function guardPathParents(repo, repoPath, mutationGuards, guarded) { const components = repoPath.split('/'); let current = repo; - const rootStat = fs.lstatSync(repo, { bigint: true }); - mutationGuards.push({ - type: 'directory', - absolute: repo, - identity: stableDirectoryIdentity(rootStat), - }); + if (!guarded.has(repo)) { + guarded.add(repo); + mutationGuards.push({ + type: 'directory', + absolute: repo, + identity: stableDirectoryIdentity(fs.lstatSync(repo, { bigint: true })), + }); + } for (const component of components.slice(0, -1)) { current = path.join(current, component); + // Already proved a real directory and already guarded on an earlier path. + if (guarded.has(current)) continue; let stat; try { stat = fs.lstatSync(current, { bigint: true }); @@ -638,6 +651,7 @@ function guardPathParents(repo, repoPath, mutationGuards) { throw new Error(`Refusing to traverse symlink parent for ${repoPath}`); } if (!stat.isDirectory()) return; + guarded.add(current); mutationGuards.push({ type: 'directory', absolute: current, @@ -646,81 +660,153 @@ function guardPathParents(repo, repoPath, mutationGuards) { } } -function recordAnchoredAbsence(repo, repoPath, mutationGuards) { - requireDescriptorAnchoring(); - const flags = - fs.constants.O_RDONLY | - fs.constants.O_DIRECTORY | - fs.constants.O_NOFOLLOW | - (fs.constants.O_CLOEXEC ?? 0); - const descriptors = []; - let retainedFd; - try { - let currentFd = fs.openSync(repo, flags); - descriptors.push(currentFd); - const components = repoPath.split('/'); - for (let index = 0; index < components.length; index += 1) { - const component = components[index]; - const child = descriptorPath(currentFd, component); - let childStat; - try { - childStat = fs.lstatSync(child, { bigint: true }); - } catch (error) { - if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') throw error; - const parentStat = fs.fstatSync(currentFd, { bigint: true }); - if (!parentStat.isDirectory()) { - throw new Error(`Absence parent is no longer a directory for ${repoPath}`); - } - retainedFd = currentFd; - mutationGuards.push({ - type: 'absence', - fd: retainedFd, - childName: component, - repoPath, - parentIdentity: stableDirectoryIdentity(parentStat), - parentMutationIdentity: statIdentity(parentStat), - }); - for (const fd of descriptors) { - if (fd !== retainedFd) fs.closeSync(fd); - } - return; - } - if (index === components.length - 1) { - throw new Error(`${repoPath} appeared while its absence was being anchored`); - } - if (childStat.isSymbolicLink() || !childStat.isDirectory()) { - throw new Error(`Refusing a non-directory parent while anchoring absence for ${repoPath}`); - } - const nextFd = fs.openSync(child, flags); - descriptors.push(nextFd); - currentFd = nextFd; - } - throw new Error(`Could not anchor absence for ${repoPath}`); - } catch (error) { - for (const fd of descriptors) { - if (fd === retainedFd) continue; - try { - fs.closeSync(fd); - } catch { - // Preserve the primary absence-anchoring error. - } - } - throw error; +// A bound, not a bug: the absence cache deduplicates correctly and leaks nothing, +// but citedPaths is caller-supplied and unbounded, so a pathological snapshot +// could hold more descriptors than the process is allowed (macOS +// kern.maxfilesperproc is 24576). The peak precedes a `git` spawn, so exhaustion +// would surface as a git failure misreported as evidence instability. +// +// Refuse rather than evict: closing a cached descriptor would silently break the +// pinned chain of an absence guard that was already recorded against it, which is +// exactly the inode-recycling hole the pins exist to close. +const ABSENCE_ANCHOR_LIMITS = Object.freeze({ maxPinnedDirectories: 4096 }); + +// Every no-follow read and every exclusive create in this file uses one of these +// two, so a change lands in one place rather than in seven. +const VERIFIED_READ_FLAGS = + fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_CLOEXEC ?? 0); +const VERIFIED_CREATE_FLAGS = + fs.constants.O_RDWR | + fs.constants.O_CREAT | + fs.constants.O_EXCL | + fs.constants.O_NOFOLLOW | + (fs.constants.O_CLOEXEC ?? 0); + +function requireAbsenceAnchorCapacity(cache) { + if (cache.size >= ABSENCE_ANCHOR_LIMITS.maxPinnedDirectories) { + throw new Error( + `Absence anchoring exceeds ${ABSENCE_ANCHOR_LIMITS.maxPinnedDirectories} pinned directories`, + ); } } -function materializeRecord(repo, statusRecord, layers, mutationGuards, testHooks) { +const ANCHORED_DIRECTORY_FLAGS = + fs.constants.O_RDONLY | + fs.constants.O_DIRECTORY | + fs.constants.O_NOFOLLOW | + (fs.constants.O_CLOEXEC ?? 0); + +// Every absence receipt is verified long after its walk returns, so the chain +// that produced it has to stay pinned until the snapshot ends — an unpinned inode +// number can be recycled by a replacement directory that then reproduces the +// recorded identity exactly. Absent cited paths overwhelmingly share prefixes, so +// the walked directories are cached per snapshot and keyed by repo-relative +// prefix: one open descriptor and one anchored walk per distinct directory rather +// than per path. snapshotEvidence owns every descriptor in this cache and closes +// each exactly once; guards only borrow them for verification. +function anchoredAbsenceRoot(repo, cache) { + const cached = cache.get(''); + if (cached) return cached; + requireAbsenceAnchorCapacity(cache); + const fd = openVerifiedDirectory(repo, ANCHORED_DIRECTORY_FLAGS); + const handle = { + fd, + expectedPath: repo, + chain: [ + { expectedPath: repo, identity: stableDirectoryIdentity(fs.fstatSync(fd, { bigint: true })) }, + ], + descriptors: [fd], + }; + cache.set('', handle); + return handle; +} + +function recordAnchoredAbsence(repo, repoPath, mutationGuards, cache) { + requireDescriptorAnchoring(); + const components = repoPath.split('/'); + let handle = anchoredAbsenceRoot(repo, cache); + let prefix = ''; + for (let index = 0; index < components.length; index += 1) { + const component = components[index]; + const isFinal = index === components.length - 1; + prefix = prefix === '' ? component : `${prefix}/${component}`; + // The final component is always re-checked against the filesystem: it is the + // one whose absence is being recorded, and a cached answer would be a stale + // one. Only the prefix directories are reused. + const cached = isFinal ? undefined : cache.get(prefix); + if (cached) { + handle = cached; + continue; + } + const child = anchoredChild(handle, component); + let childStat; + try { + childStat = lstatChild(child); + } catch (error) { + if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') throw error; + const parentStat = fs.fstatSync(handle.fd, { bigint: true }); + if (!parentStat.isDirectory()) { + throw new Error(`Absence parent is no longer a directory for ${repoPath}`); + } + mutationGuards.push({ + type: 'absence', + // The handle is the holder the guard verifies against, and `ref` is the + // child path already built through the anchoredChild chokepoint — the + // guard must never re-derive that name itself. + handle, + ref: child, + fd: handle.fd, + repoPath, + parentMutationIdentity: statIdentity(parentStat), + }); + return; + } + if (isFinal) { + throw new Error(`${repoPath} appeared while its absence was being anchored`); + } + if (childStat.isSymbolicLink() || !childStat.isDirectory()) { + throw new Error(`Refusing a non-directory parent while anchoring absence for ${repoPath}`); + } + requireAbsenceAnchorCapacity(cache); + const childFd = openVerifiedDirectory(child.path, ANCHORED_DIRECTORY_FLAGS); + const expectedPath = path.join(handle.expectedPath, component); + let next; + try { + if (!anchoringBackend().descriptorMatchesChild(childFd, expectedPath, childStat)) { + throw new Error( + `Absence parent descriptor does not match its verified inode for ${repoPath}`, + ); + } + next = { + fd: childFd, + expectedPath, + chain: [...handle.chain, { expectedPath, identity: stableDirectoryIdentity(childStat) }], + descriptors: [...handle.descriptors, childFd], + }; + } catch (error) { + fs.closeSync(childFd); + throw error; + } + cache.set(prefix, next); + handle = next; + } + throw new Error(`Could not anchor absence for ${repoPath}`); +} + +function materializeRecord(repo, statusRecord, layers, mutationGuards, testHooks, walkState) { const head = layers.head(statusRecord.path); const index = layers.index(statusRecord.path); const expectedKind = index.kind === 'gitlink' || head.kind === 'gitlink' ? 'gitlink' : null; - guardPathParents(repo, statusRecord.path, mutationGuards); + guardPathParents(repo, statusRecord.path, mutationGuards, walkState.guardedDirectories); const filesystem = filesystemObject( path.join(repo, ...statusRecord.path.split('/')), expectedKind, mutationGuards, testHooks, ); - if (filesystem.kind === ABSENT) recordAnchoredAbsence(repo, statusRecord.path, mutationGuards); + if (filesystem.kind === ABSENT) { + recordAnchoredAbsence(repo, statusRecord.path, mutationGuards, walkState.absenceCache); + } if (statusRecord.directory_hint && filesystem.kind !== 'directory') { throw new Error( `Git reported an embedded directory but found ${filesystem.kind}: ${statusRecord.path}`, @@ -789,9 +875,15 @@ export function serializeDirtyRecords(entries) { } function assertRepository(repoInput) { - const repo = fs.realpathSync(requireString(repoInput, 'repo')); + // realpathSync.native, not realpathSync: the JS resolver preserves a Windows + // 8.3 short component (C:\Users\RUNNER~1\...) while git always reports the long + // form, so the two would never compare equal and every caller would be told the + // worktree root is not the worktree root it just named. + const repo = fs.realpathSync.native(requireString(repoInput, 'repo')); const topLevelResult = git(repo, ['rev-parse', '--show-toplevel']); - const topLevel = fs.realpathSync(decodeUtf8(topLevelResult.stdout, 'repository root').trim()); + const topLevel = fs.realpathSync.native( + decodeUtf8(topLevelResult.stdout, 'repository root').trim(), + ); if (topLevel !== repo) throw new Error(`--repo must be the Git worktree root (${topLevel})`); return repo; } @@ -882,17 +974,48 @@ function stableFileIdentity(stat) { return [stat.dev, stat.ino, stat.mode, stat.size].map(String).join(':'); } +// The two backends below differ in one decisive way, and it is worth stating +// plainly because the security properties are not the same. +// +// Linux ANCHORS. A name is resolved through /proc/self/fd//, which +// starts the walk at the inode the descriptor holds, so a parent that is renamed +// away cannot be traversed at all: the descriptor keeps pointing at the original +// directory and the impostor planted at the same name is simply never reached. +// +// macOS VERIFIES. Node cannot resolve a name relative to a descriptor there — +// /dev/fd/ is not a magic link (it stats as the directory but every attempt +// to traverse a child through it returns ENOENT), and fcntl F_GETPATH is a +// name-cache snapshot rather than a live anchor. So the Darwin backend resolves +// lexically, holds an open descriptor on every element of the chain, and proves +// before and after each operation that the path chain still names exactly the +// inodes it is holding. That DETECTS a swapped parent and aborts the write; it +// does not make the swap impossible the way the Linux path does. A swap landing +// inside the window between a check and the call it guards is caught by the +// following check, after the fact, rather than being unreachable. +// +// Every other platform gets neither and is refused outright. function requireDescriptorAnchoring() { - if ( - process.platform !== 'linux' || - fs.constants.O_DIRECTORY === undefined || - fs.constants.O_NOFOLLOW === undefined || - !fs.existsSync('/proc/self/fd') - ) { - throw new Error( - 'Safe generated-plan writes require Linux /proc/self/fd and O_DIRECTORY/O_NOFOLLOW; refusing an unanchored write', - ); + const directoryFlagsAvailable = + fs.constants.O_DIRECTORY !== undefined && fs.constants.O_NOFOLLOW !== undefined; + if (process.platform === 'linux') { + if (!directoryFlagsAvailable || !fs.existsSync('/proc/self/fd')) { + throw new Error( + 'Safe generated-plan writes require Linux /proc/self/fd and O_DIRECTORY/O_NOFOLLOW; refusing an unanchored write', + ); + } + return; } + if (process.platform === 'darwin') { + if (!directoryFlagsAvailable) { + throw new Error( + 'Safe generated-plan writes require macOS O_DIRECTORY/O_NOFOLLOW; refusing an unverified write', + ); + } + return; + } + throw new Error( + `Safe generated-plan writes require Linux /proc/self/fd or macOS O_DIRECTORY/O_NOFOLLOW; ${process.platform} offers neither, so refusing an unanchored write`, + ); } function descriptorPath(fd, childName) { @@ -900,157 +1023,352 @@ function descriptorPath(fd, childName) { return childName === undefined ? base : path.join(base, childName); } -function externalDescriptorPath(fd, childName) { - const base = `/proc/${process.pid}/fd/${fd}`; - return childName === undefined ? base : path.join(base, childName); +// Directory opens are plain O_RDONLY|O_DIRECTORY|O_NOFOLLOW|O_CLOEXEC on both +// platforms, and deliberately nothing else. +// +// O_NOFOLLOW_ANY (macOS 11+) used to be ORed in here on the theory that XNU +// ignores unrecognized open flag bits, so it would be inert where unsupported. +// That was wrong: combined with O_DIRECTORY macOS rejects it outright with +// EINVAL, and every directory open on Darwin failed. It is gone and is not +// coming back behind a probe or a degrade-on-EINVAL path — the per-component +// O_NOFOLLOW walk is what delivers the guarantee. Rust's cap-std, the closest +// reference implementation of this problem, has not adopted O_NOFOLLOW_ANY +// either (their issue #179 is still open). +function openVerifiedDirectory(absolute, flags) { + return fs.openSync(absolute, flags); } -const RENAME_NOREPLACE_SCRIPT = String.raw` -import ctypes -import errno -import os -import sys - -libc = ctypes.CDLL(None, use_errno=True) -try: - renameat2 = libc.renameat2 -except AttributeError: - print("libc does not expose renameat2", file=sys.stderr) - raise SystemExit(125) - -renameat2.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint] -renameat2.restype = ctypes.c_int -result = renameat2(-100, os.fsencode(sys.argv[1]), -100, os.fsencode(sys.argv[2]), 1) -if result != 0: - error_number = ctypes.get_errno() - error_name = errno.errorcode.get(error_number, "UNKNOWN") - print(f"renameat2 RENAME_NOREPLACE failed: {error_name}: {os.strerror(error_number)}", file=sys.stderr) - raise SystemExit(17 if error_number == errno.EEXIST else 126) -`; - -let atomicMoverPath; - -function spawnHeldExecutable(executable, args, options) { - const before = fs.fstatSync(executable.fd, { bigint: true }); - if (!before.isFile() || statIdentity(before) !== executable.identity) { - throw new Error('Validated Python executable changed before invocation'); - } - const result = spawnSync('/proc/self/fd/3', args, { - ...options, - stdio: ['ignore', 'pipe', 'pipe', executable.fd], - }); - const after = fs.fstatSync(executable.fd, { bigint: true }); - assertStableIdentity(before, after, 'validated Python executable'); - return result; +// File opens additionally get O_NONBLOCK, which directory opens do not need: +// it stops a FIFO swapped in at the target name from wedging the process on +// open. The identity comparison that follows rejects the FIFO anyway, but only +// if we ever get as far as running it. +function openVerifiedFile(absolute, flags, mode) { + const nonBlocking = flags | (fs.constants.O_NONBLOCK ?? 0); + return mode === undefined + ? fs.openSync(absolute, nonBlocking) + : fs.openSync(absolute, nonBlocking, mode); } -function validatedPathExecutable(candidate) { - if (!path.isAbsolute(candidate)) return null; - const candidateDirectory = path.dirname(candidate); - let resolvedDirectory; - let resolved; - let directoryStats; - let executableStat; +// The publish primitive, identical on both platforms. +// +// link() is the portable no-replace publish: it fails with EEXIST if the +// destination name is taken — by a regular file, by a directory, or by a symlink, +// live or dangling — and it never follows that symlink to clobber its target. +// It also works where renameat2(RENAME_NOREPLACE) does not, notably v9fs, which +// is why the WSL2 9p case that used to fail every time now works. +// +// The published file is the same inode as the temporary, so every identity +// comparison the callers already make still holds, and validateCommittedPlan +// becomes strictly stronger: it compares the destination against the exact inode +// whose bytes were fsynced. +// +// On Linux both paths are /proc/self/fd//, so the publish is anchored +// to the held parent descriptors exactly like every other operation. +// link(2) BUGS: "On NFS filesystems, the return code may be wrong in case the NFS +// server performs the link creation and dies before it can say so. Use stat(2) to +// find out if the link got created." open(2) NOTES gives the remedy this +// implements: on a reported failure, stat the source and see whether its link +// count reached 2. A false positive would need someone to have hardlinked a +// 16-random-byte name inside a directory we hold open — and validateCommittedPlan +// still proves the destination is the exact temporary inode afterwards. +function linkCreatedDespiteError(sourcePath) { try { - resolvedDirectory = fs.realpathSync(candidateDirectory); - resolved = fs.realpathSync(candidate); - const resolvedExecutableDirectory = fs.realpathSync(path.dirname(resolved)); - directoryStats = [...new Set([resolvedDirectory, resolvedExecutableDirectory])].map( - (directory) => fs.statSync(directory), - ); - executableStat = fs.lstatSync(resolved); - fs.accessSync(resolved, fs.constants.X_OK); + return fs.statSync(sourcePath, { bigint: true }).nlink === 2n; } catch { - return null; + return false; } - if ( - directoryStats.some((stat) => !stat.isDirectory()) || - !executableStat.isFile() || - executableStat.isSymbolicLink() - ) { - return null; - } - const uid = typeof process.getuid === 'function' ? process.getuid() : null; - const trustedOwner = (stat) => uid === null || stat.uid === 0 || stat.uid === uid; - if ( - directoryStats.some((stat) => !trustedOwner(stat) || (stat.mode & 0o022) !== 0) || - !trustedOwner(executableStat) || - (executableStat.mode & 0o022) !== 0 - ) { - return null; - } - return resolved; } -function resolveAtomicMover() { - if (atomicMoverPath) return atomicMoverPath; - const candidates = new Set(); - for (const entry of (process.env.PATH ?? '').split(path.delimiter)) { - if (entry && path.isAbsolute(entry)) candidates.add(path.join(entry, 'python3')); - } - for (const entry of ['/usr/local/bin/python3', '/usr/bin/python3', '/bin/python3']) { - candidates.add(entry); - } - for (const candidate of candidates) { - const resolved = validatedPathExecutable(candidate); - if (!resolved) continue; - let fd; - try { - fd = fs.openSync( - resolved, - fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_CLOEXEC ?? 0), - ); - } catch { - continue; +function linkNoReplace(sourcePath, destinationPath) { + try { + fs.linkSync(sourcePath, destinationPath); + } catch (error) { + // Callers treat "destination taken" as a distinct outcome, not a failure. + if (error?.code === 'EEXIST') return false; + if (!linkCreatedDespiteError(sourcePath)) { + // FAT, Coda, and some SMB/FUSE/virtiofs mounts have no hardlinks at all. + // Git falls back to rename here, but git can afford to lose collision + // detection because its objects are content-addressed; a plan destination + // is a plain name, so a replacing rename would silently clobber whatever + // is already there. Refuse loudly instead. + if (error?.code === 'EPERM' || error?.code === 'ENOTSUP' || error?.code === 'EMLINK') { + throw new Error( + `Generated-plan publication requires hard links, which this filesystem refused (${error.code}); refusing to fall back to a replacing rename`, + ); + } + throw error; } - const opened = fs.fstatSync(fd, { bigint: true }); - const executable = { fd, identity: statIdentity(opened), resolved }; - const version = spawnHeldExecutable( - executable, - ['-I', '-S', '-c', 'import sys; print(sys.version_info[0])'], - { - encoding: 'utf8', - env: { ...process.env, LANG: 'C', LC_ALL: 'C' }, - timeout: 10_000, - windowsHide: true, - }, - ); - if (version.status === 0 && version.stdout.trim() === '3') { - atomicMoverPath = executable; - return executable; - } - fs.closeSync(fd); } - throw new Error( - 'Safe generated-plan publication requires a trusted absolute Python 3 PATH candidate with libc renameat2 support', - ); -} - -function atomicMoveNoReplace(source, destination) { - const mover = resolveAtomicMover(); - const result = spawnHeldExecutable( - mover, - ['-I', '-S', '-c', RENAME_NOREPLACE_SCRIPT, source, destination], - { - encoding: 'utf8', - env: { ...process.env, LANG: 'C', LC_ALL: 'C' }, - timeout: 10_000, - windowsHide: true, - }, - ); - if (result.error) throw result.error; - if (result.status === 17) return false; - if (result.status !== 0) { - throw new Error( - `Atomic no-replace move failed (${result.status}): ${(result.stderr ?? '').trim()}`, - ); + try { + fs.unlinkSync(sourcePath); + } catch { + // The link succeeded, so the plan IS published. A temporary name left behind + // is a stray file, not an unpublished plan: reporting it as a failure would + // be a lie, and rolling back would unpublish a plan that is already live. } return true; } -function lstatOptional(absolute) { +// A directory holder is anything that owns a verified chain: a plan-parent +// handle, a ref's parent directory, or an absence guard. Two arrays describe it, +// both root-first and the same length — `chain` records each element's expected +// path and dev/ino/mode, and `descriptors` holds an open descriptor on each. +// +// Holding those descriptors is load-bearing rather than decorative. dev/ino/mode +// is unique only among *live* inodes: an inode number freed by an rmdir is handed +// straight back to the next mkdir, so a replacement directory can reproduce a +// recorded identity exactly. An open descriptor pins the inode, so the number +// cannot be recycled for as long as the holder exists. +function verifyPinnedDescriptors(holder) { + const { chain, descriptors } = holder; + if (!Array.isArray(descriptors) || descriptors.length !== chain.length) { + throw new Error('Generated-plan parent chain is missing the descriptors that pin it'); + } + chain.forEach((item, index) => { + const pinned = fs.fstatSync(descriptors[index], { bigint: true }); + if (!pinned.isDirectory() || stableDirectoryIdentity(pinned) !== item.identity) { + throw new Error('Generated-plan parent descriptor changed during the write'); + } + }); +} + +function verifyLexicalChain(holder) { + for (const item of holder.chain) { + let lexical; + try { + lexical = fs.lstatSync(item.expectedPath, { bigint: true }); + } catch (error) { + if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') throw error; + // A parent renamed out from under us is a mismatch, not a missing file: + // reporting the raw ENOENT would leak an unrelated-looking error out of a + // check whose whole job is to say the chain no longer holds. + throw new Error('Generated-plan lexical parent no longer matches its directory descriptor'); + } + if ( + lexical.isSymbolicLink() || + !lexical.isDirectory() || + stableDirectoryIdentity(lexical) !== item.identity + ) { + throw new Error('Generated-plan lexical parent no longer matches its directory descriptor'); + } + } +} + +// The whole platform seam, in five methods. Everything else an operation does is +// identical on both platforms and lives in the shared functions below. +// +// Only two things actually differ: how a name becomes a path, and what guard +// wraps the operation that uses it. +// +// Linux ANCHORS. /proc/self/fd// starts the walk at the inode the +// descriptor holds, so a parent renamed away cannot be traversed at all and the +// guard is a no-op — there is nothing left to verify. +// +// macOS VERIFIES. It resolves lexically, so before and after every operation it +// proves that each element of the path chain still names the exact inode being +// held for it. That DETECTS a swapped parent and aborts; it does not make the +// swap impossible. A swap landing inside the window is caught by the trailing +// check, after the fact, rather than being unreachable. The check runs after a +// failure too, because a verdict observed through a chain that has since changed +// is not a verdict. +const LINUX_ANCHORING = { + childPath(dirHandle, childName) { + return descriptorPath(dirHandle.fd, childName); + }, + verified(holders, run) { + return run(); + }, + descriptorMatchesChild(fd, expectedPath) { + return fs.realpathSync.native(descriptorPath(fd)) === expectedPath; + }, + parentStillResolves(parentHandle) { + return fs.realpathSync.native(descriptorPath(parentHandle.fd)) === parentHandle.expectedPath; + }, + verifyAbsentChild(guard) { + if (absentChildIsPresent(guard.ref)) { + throw new Error(`${guard.repoPath} appeared before evidence materialization completed`); + } + }, +}; + +const DARWIN_ANCHORING = { + childPath(dirHandle, childName) { + return path.join(dirHandle.expectedPath, childName); + }, + verified(holders, run) { + const list = Array.isArray(holders) ? holders : [holders]; + const proveChain = () => { + for (const holder of list) { + verifyPinnedDescriptors(holder); + verifyLexicalChain(holder); + } + }; + proveChain(); + let value; + try { + value = run(); + } catch (error) { + proveChain(); + throw error; + } + proveChain(); + return value; + }, + descriptorMatchesChild(fd, _expectedPath, childStat) { + // There is no live fd-to-path oracle on macOS (F_GETPATH is a name-cache + // snapshot, not an anchor), so escape is decided the other way round: the + // name was just resolved under a verified chain, and the descriptor opened + // from it counts only if it is that same inode. + const opened = fs.fstatSync(fd, { bigint: true }); + return ( + opened.isDirectory() && stableDirectoryIdentity(opened) === stableDirectoryIdentity(childStat) + ); + }, + parentStillResolves(parentHandle) { + // Both halves are needed: a directory renamed away keeps its inode, so the + // descriptors alone still match and only the lexical half notices it moved. + try { + verifyPinnedDescriptors(parentHandle); + verifyLexicalChain(parentHandle); + } catch { + return false; + } + return true; + }, + verifyAbsentChild(guard) { + let present; + try { + present = DARWIN_ANCHORING.verified(guard.handle, () => absentChildIsPresent(guard.ref)); + } catch (error) { + // A chain that no longer holds makes the absence verdict meaningless, and + // the caller reports that as the anchor changing rather than as a stray + // parent-descriptor error. Linux cannot reach this: its guard is a no-op. + throw new Error( + `Absence anchor changed for ${guard.repoPath}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + if (present) { + throw new Error(`${guard.repoPath} appeared before evidence materialization completed`); + } + }, +}; + +const ANCHORING_BACKENDS = new Map([ + ['linux', LINUX_ANCHORING], + ['darwin', DARWIN_ANCHORING], +]); + +function anchoringBackend() { + const backend = ANCHORING_BACKENDS.get(process.platform); + if (!backend) { + // requireDescriptorAnchoring normally refuses first; this is the same answer + // from the other side, so an unsupported platform can never fall through to + // whichever backend happened to be the ternary's default. + throw new Error( + `No generated-plan anchoring backend for ${process.platform}; refusing an unanchored write`, + ); + } + return backend; +} + +// Open, fstat, compare, close on mismatch. The descriptor never escapes this +// function unless it refers to the inode the caller already verified by name, so +// a lexical open that landed anywhere else cannot be used by accident. On Linux +// the comparison passes trivially — the /proc walk already resolved from the +// held parent — and costs one fstat to keep the guarantee structural rather than +// dependent on which backend is in play. +function adoptVerifiedFile(ref, expectedStat, flags) { + const fd = openVerifiedFile(ref.path, flags); + let opened; try { - return fs.lstatSync(absolute, { bigint: true }); + opened = fs.fstatSync(fd, { bigint: true }); + } catch (error) { + fs.closeSync(fd); + throw error; + } + if (stableFileIdentity(opened) !== stableFileIdentity(expectedStat)) { + fs.closeSync(fd); + return null; + } + return fd; +} + +function absentChildIsPresent(ref) { + try { + fs.lstatSync(ref.path, { bigint: true }); + } catch (error) { + if (error?.code === 'ENOENT') return false; + throw error; + } + return true; +} + +// The operations. Each is the same on both platforms; only the guard differs. +function lstatChild(ref) { + return anchoringBackend().verified(ref.dir, () => fs.lstatSync(ref.path, { bigint: true })); +} + +function openChildRead(ref, flags, expectedStat) { + return anchoringBackend().verified(ref.dir, () => { + const fd = adoptVerifiedFile(ref, expectedStat, flags); + if (fd === null) { + throw new Error(`${ref.name} was replaced between its verified stat and its no-follow open`); + } + return fd; + }); +} + +function createChild(ref, flags, mode) { + // O_CREAT|O_EXCL|O_NOFOLLOW is atomic at the leaf, so the only thing the guard + // has to cover is which directory the leaf landed in. + return anchoringBackend().verified(ref.dir, () => openVerifiedFile(ref.path, flags, mode)); +} + +function mkdirChild(ref, mode) { + anchoringBackend().verified(ref.dir, () => fs.mkdirSync(ref.path, { mode })); +} + +function publishNoReplace(sourceRef, destinationRef) { + return anchoringBackend().verified([sourceRef.dir, destinationRef.dir], () => + linkNoReplace(sourceRef.path, destinationRef.path), + ); +} + +// The single place a name becomes a path, and therefore the right place to +// enforce that a name is one ordinary component. +// +// A trailing separator is the sharp edge here, not a tidiness concern: +// open(path, O_NOFOLLOW) FOLLOWS a symlink when path ends in "/" — the trap +// behind CVE-2026-39822 / golang/go#79005, which let os.Root escape its own +// root. path.join preserves that trailing slash, so a component carrying one +// would turn every no-follow open in this file into a following one. +// normalizeRepoPath already rejects such components upstream; this is the +// chokepoint that makes it true for every caller, including the generated +// temporary and vault names that never pass through it. +function anchoredChild(dirHandle, childName) { + if ( + typeof childName !== 'string' || + childName === '' || + childName === '.' || + childName === '..' || + childName.includes('/') || + childName.includes('\\') || + childName.includes('\0') + ) { + throw new Error(`Refusing to resolve ${JSON.stringify(childName)} as a single path component`); + } + return { + dir: dirHandle, + name: childName, + path: anchoringBackend().childPath(dirHandle, childName), + }; +} + +function lstatAnchoredOptional(ref) { + try { + return lstatChild(ref); } catch (error) { if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return null; throw error; @@ -1063,39 +1381,37 @@ function openPlanParent( { createMissing = true, purpose = 'Generated-plan' } = {}, ) { requireDescriptorAnchoring(); - const flags = - fs.constants.O_RDONLY | - fs.constants.O_DIRECTORY | - fs.constants.O_NOFOLLOW | - (fs.constants.O_CLOEXEC ?? 0); + // Root-first and index-aligned with `chain`: verifyPinnedDescriptors relies on + // that, and the descriptors are what pin each recorded inode against reuse. const descriptors = []; try { - let currentFd = fs.openSync(repo, flags); + let currentFd = openVerifiedDirectory(repo, ANCHORED_DIRECTORY_FLAGS); descriptors.push(currentFd); const rootStat = fs.fstatSync(currentFd, { bigint: true }); const chain = [{ expectedPath: repo, identity: stableDirectoryIdentity(rootStat) }]; + let currentHandle = { fd: currentFd, expectedPath: repo, chain, descriptors }; const traversed = []; for (const component of parentComponents) { traversed.push(component); - const anchoredChild = descriptorPath(currentFd, component); + const child = anchoredChild(currentHandle, component); let childStat; let created = false; try { - childStat = fs.lstatSync(anchoredChild, { bigint: true }); + childStat = lstatChild(child); } catch (error) { if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') throw error; if (!createMissing) { throw new Error(`${purpose} parent does not exist: ${traversed.join('/')}`); } - fs.mkdirSync(anchoredChild, { mode: 0o755 }); - childStat = fs.lstatSync(anchoredChild, { bigint: true }); + mkdirChild(child, 0o755); + childStat = lstatChild(child); created = true; } if (childStat.isSymbolicLink() || !childStat.isDirectory()) { throw new Error(`${purpose} parent is not a real directory: ${traversed.join('/')}`); } const parentFd = currentFd; - const childFd = fs.openSync(anchoredChild, flags); + const childFd = openVerifiedDirectory(child.path, ANCHORED_DIRECTORY_FLAGS); descriptors.push(childFd); currentFd = childFd; if (created) { @@ -1103,18 +1419,16 @@ function openPlanParent( fs.fsyncSync(parentFd); } const expected = path.join(repo, ...traversed); - const actual = fs.realpathSync(descriptorPath(currentFd)); - if (actual !== expected) { + if (!anchoringBackend().descriptorMatchesChild(currentFd, expected, childStat)) { throw new Error(`${purpose} parent escaped the repository: ${traversed.join('/')}`); } const openedStat = fs.fstatSync(currentFd, { bigint: true }); chain.push({ expectedPath: expected, identity: stableDirectoryIdentity(openedStat) }); + currentHandle = { fd: currentFd, expectedPath: expected, chain, descriptors }; } - const stat = fs.fstatSync(currentFd, { bigint: true }); return { descriptors, fd: currentFd, - identity: stableDirectoryIdentity(stat), expectedPath: path.join(repo, ...parentComponents), chain, }; @@ -1134,9 +1448,16 @@ function closeDescriptors(descriptors) { } } +// A handle's identity IS its chain leaf's identity. Storing it twice meant two +// fstats a line apart and a re-stamp helper to keep them agreeing; deriving it +// removes both. +function handleIdentity(handle) { + return handle.chain[handle.chain.length - 1].identity; +} + function resolveGitDirectory(repo) { const result = git(repo, ['rev-parse', '--absolute-git-dir']); - return fs.realpathSync(decodeUtf8(result.stdout, 'Git administrative directory').trim()); + return fs.realpathSync.native(decodeUtf8(result.stdout, 'Git administrative directory').trim()); } function openBackupVault(repo, { createMissing = true } = {}) { @@ -1147,9 +1468,12 @@ function openBackupVault(repo, { createMissing = true } = {}) { }); fs.fchmodSync(handle.fd, 0o700); fs.fsyncSync(handle.fd); - const stat = fs.fstatSync(handle.fd, { bigint: true }); - handle.identity = stableDirectoryIdentity(stat); - handle.chain[handle.chain.length - 1].identity = handle.identity; + // mode is part of every directory identity, so hardening the vault changes the + // identity the chain recorded for it; without this the next verification would + // reject the directory it just hardened. + handle.chain[handle.chain.length - 1].identity = stableDirectoryIdentity( + fs.fstatSync(handle.fd, { bigint: true }), + ); return { ...handle, gitDirectory }; } @@ -1157,33 +1481,28 @@ function validatePlanParent(parentHandle) { const descriptorStat = fs.fstatSync(parentHandle.fd, { bigint: true }); if ( !descriptorStat.isDirectory() || - stableDirectoryIdentity(descriptorStat) !== parentHandle.identity + stableDirectoryIdentity(descriptorStat) !== handleIdentity(parentHandle) ) { throw new Error('Generated-plan parent descriptor changed during the write'); } - const descriptorRealPath = fs.realpathSync(descriptorPath(parentHandle.fd)); - if (descriptorRealPath !== parentHandle.expectedPath) { + if (!anchoringBackend().parentStillResolves(parentHandle)) { throw new Error('Generated-plan parent moved or was replaced during the write'); } - for (const item of parentHandle.chain) { - const lexicalStat = fs.lstatSync(item.expectedPath, { bigint: true }); - if ( - lexicalStat.isSymbolicLink() || - !lexicalStat.isDirectory() || - stableDirectoryIdentity(lexicalStat) !== item.identity - ) { - throw new Error('Generated-plan lexical parent no longer matches its directory descriptor'); - } - } + // Both halves come from the shared helpers rather than being restated here: an + // earlier hand-copy of the lexical loop lost verifyLexicalChain's ENOENT/ENOTDIR + // translation, so a renamed parent could surface a raw errno from a function + // with a dozen call sites. + verifyPinnedDescriptors(parentHandle); + verifyLexicalChain(parentHandle); } function inspectPlanDestination( - finalPath, + finalRef, { replace, expectedIdentity, mustBeAbsent = false } = {}, ) { let stat; try { - stat = fs.lstatSync(finalPath, { bigint: true }); + stat = lstatChild(finalRef); } catch (error) { if (error?.code === 'ENOENT') { if (expectedIdentity) throw new Error('Generated plan disappeared during the write'); @@ -1201,19 +1520,17 @@ function inspectPlanDestination( if (expectedIdentity && identity !== expectedIdentity) { throw new Error('Generated plan changed during the write'); } - return identity; + return stat; } -function openExistingPlanDestination(finalPath, replace) { - const identity = inspectPlanDestination(finalPath, { replace }); - if (identity === null) { +function openExistingPlanDestination(finalRef, replace) { + const stat = inspectPlanDestination(finalRef, { replace }); + if (stat === null) { if (replace) throw new Error('Deepen mode requires an existing generated plan to replace'); return { fd: undefined, identity: null, stableIdentity: null }; } - const fd = fs.openSync( - finalPath, - fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_CLOEXEC ?? 0), - ); + const identity = statIdentity(stat); + const fd = openChildRead(finalRef, VERIFIED_READ_FLAGS, stat); try { const opened = fs.fstatSync(fd, { bigint: true }); if (!opened.isFile() || statIdentity(opened) !== identity) { @@ -1264,8 +1581,8 @@ function hashOpenFile(fd, label) { }; } -function validateCommittedPlan(finalPath, tempFd, expectedTemp, testHooks) { - const before = fs.lstatSync(finalPath, { bigint: true }); +function validateCommittedPlan(finalRef, tempFd, expectedTemp, testHooks) { + const before = lstatChild(finalRef); if ( before.isSymbolicLink() || !before.isFile() || @@ -1273,19 +1590,16 @@ function validateCommittedPlan(finalPath, tempFd, expectedTemp, testHooks) { ) { throw new Error('Generated-plan destination failed its first post-write identity check'); } - const finalFd = fs.openSync( - finalPath, - fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_CLOEXEC ?? 0), - ); + const finalFd = openChildRead(finalRef, VERIFIED_READ_FLAGS, before); try { const opened = fs.fstatSync(finalFd, { bigint: true }); if (!opened.isFile() || stableFileIdentity(opened) !== expectedTemp.identity) { throw new Error('Generated-plan destination changed while its no-follow descriptor opened'); } - testHooks?.afterFinalOpen?.({ fd: finalFd, finalPath }); + testHooks?.afterFinalOpen?.({ fd: finalFd, finalPath: finalRef.path }); const committedViaTemp = hashOpenFile(tempFd, 'generated-plan committed file'); const committedViaPath = hashOpenFile(finalFd, 'generated-plan destination descriptor'); - const after = fs.lstatSync(finalPath, { bigint: true }); + const after = lstatChild(finalRef); const openedAfter = fs.fstatSync(finalFd, { bigint: true }); if ( after.isSymbolicLink() || @@ -1320,22 +1634,19 @@ function copyOpenFile(sourceFd, destinationFd, label) { return after; } -function openVerifiedPathFile(absolute, label) { - const before = fs.lstatSync(absolute, { bigint: true }); +function openVerifiedAnchoredFile(ref, label, knownStat) { + const before = knownStat ?? lstatChild(ref); if (before.isSymbolicLink() || !before.isFile()) { throw new Error(`${label} is not a regular no-follow file`); } - const fd = fs.openSync( - absolute, - fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_CLOEXEC ?? 0), - ); + const fd = openChildRead(ref, VERIFIED_READ_FLAGS, before); try { const opened = fs.fstatSync(fd, { bigint: true }); if (!opened.isFile() || stableFileIdentity(opened) !== stableFileIdentity(before)) { throw new Error(`${label} changed while its descriptor opened`); } const layer = hashOpenFile(fd, label); - const after = fs.lstatSync(absolute, { bigint: true }); + const after = lstatChild(ref); if (after.isSymbolicLink() || !after.isFile() || stableFileIdentity(after) !== layer.identity) { throw new Error(`${label} changed after verification`); } @@ -1358,10 +1669,10 @@ export function readPlanSafely({ repo: repoInput, generatedPlanPath, testHooks } let fd; try { validatePlanParent(parentHandle); - const finalPath = descriptorPath(parentHandle.fd, finalName); + const finalRef = anchoredChild(parentHandle, finalName); let before; try { - before = fs.lstatSync(finalPath, { bigint: true }); + before = lstatChild(finalRef); } catch (error) { if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') { throw new Error(`Loaded plan does not exist: ${generatedPlan}`); @@ -1371,15 +1682,12 @@ export function readPlanSafely({ repo: repoInput, generatedPlanPath, testHooks } if (before.isSymbolicLink() || !before.isFile()) { throw new Error('Loaded plan must be a regular file, never a symlink'); } - fd = fs.openSync( - finalPath, - fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_CLOEXEC ?? 0), - ); + fd = openChildRead(finalRef, VERIFIED_READ_FLAGS, before); const opened = fs.fstatSync(fd, { bigint: true }); if (!opened.isFile() || statIdentity(opened) !== statIdentity(before)) { throw new Error('Loaded plan changed while its no-follow descriptor opened'); } - testHooks?.afterPlanOpen?.({ fd, finalPath }); + testHooks?.afterPlanOpen?.({ fd, finalPath: finalRef.path }); const chunks = []; let total = 0; const buffer = Buffer.allocUnsafe(64 * 1024); @@ -1394,7 +1702,7 @@ export function readPlanSafely({ repo: repoInput, generatedPlanPath, testHooks } decodeUtf8(contents, 'loaded plan'); const after = fs.fstatSync(fd, { bigint: true }); assertStableIdentity(opened, after, 'loaded plan'); - const pathAfter = fs.lstatSync(finalPath, { bigint: true }); + const pathAfter = lstatChild(finalRef); if ( pathAfter.isSymbolicLink() || !pathAfter.isFile() || @@ -1419,24 +1727,22 @@ function artifactGitPath(name) { return `gitnexus-plan-backups/${name}`; } -function verifyVaultArtifactFromFreshRoot(repo, gitPath, expectedLayer) { - const components = gitPath.split('/'); - if (components.length !== 2 || components[0] !== 'gitnexus-plan-backups') { - throw new Error(`Invalid Git-admin artifact path: ${gitPath}`); - } +function verifyVaultArtifactFromFreshRoot(repo, name, expectedLayer) { const freshVault = openBackupVault(repo, { createMissing: false }); try { validatePlanParent(freshVault); - const opened = openVerifiedPathFile( - descriptorPath(freshVault.fd, components[1]), - `Git-admin artifact ${gitPath}`, + const opened = openVerifiedAnchoredFile( + anchoredChild(freshVault, name), + `Git-admin artifact ${artifactGitPath(name)}`, ); try { if ( opened.layer.identity !== expectedLayer.identity || opened.layer.digest !== expectedLayer.digest ) { - throw new Error(`Git-admin artifact changed before fresh-root verification: ${gitPath}`); + throw new Error( + `Git-admin artifact changed before fresh-root verification: ${artifactGitPath(name)}`, + ); } } finally { fs.closeSync(opened.fd); @@ -1449,16 +1755,8 @@ function verifyVaultArtifactFromFreshRoot(repo, gitPath, expectedLayer) { function createVaultCopyFromFd(repo, vault, sourceFd, role) { validatePlanParent(vault); const name = `.gitnexus-plan-${role}-${process.pid}-${randomBytes(16).toString('hex')}.bak`; - const absolute = descriptorPath(vault.fd, name); - const destinationFd = fs.openSync( - absolute, - fs.constants.O_RDWR | - fs.constants.O_CREAT | - fs.constants.O_EXCL | - fs.constants.O_NOFOLLOW | - (fs.constants.O_CLOEXEC ?? 0), - 0o600, - ); + const artifact = anchoredChild(vault, name); + const destinationFd = createChild(artifact, VERIFIED_CREATE_FLAGS, 0o600); let destination; try { const sourceStat = copyOpenFile(sourceFd, destinationFd, role); @@ -1469,7 +1767,7 @@ function createVaultCopyFromFd(repo, vault, sourceFd, role) { if (source.size !== destination.size || source.digest !== destination.digest) { throw new Error(`${role} vault copy does not match its held source descriptor`); } - const pathStat = fs.lstatSync(absolute, { bigint: true }); + const pathStat = lstatChild(artifact); if ( pathStat.isSymbolicLink() || !pathStat.isFile() || @@ -1481,24 +1779,15 @@ function createVaultCopyFromFd(repo, vault, sourceFd, role) { } finally { fs.closeSync(destinationFd); } - const gitPath = artifactGitPath(name); - verifyVaultArtifactFromFreshRoot(repo, gitPath, destination); - return { role, gitPath, layer: destination }; + verifyVaultArtifactFromFreshRoot(repo, name, destination); + return { role, gitPath: artifactGitPath(name), layer: destination }; } function createVaultCopyFromBytes(repo, vault, contents, role) { validatePlanParent(vault); const name = `.gitnexus-plan-${role}-${process.pid}-${randomBytes(16).toString('hex')}.bak`; - const absolute = descriptorPath(vault.fd, name); - const fd = fs.openSync( - absolute, - fs.constants.O_RDWR | - fs.constants.O_CREAT | - fs.constants.O_EXCL | - fs.constants.O_NOFOLLOW | - (fs.constants.O_CLOEXEC ?? 0), - 0o600, - ); + const artifact = anchoredChild(vault, name); + const fd = createChild(artifact, VERIFIED_CREATE_FLAGS, 0o600); let layer; try { writeAll(fd, contents); @@ -1508,7 +1797,7 @@ function createVaultCopyFromBytes(repo, vault, contents, role) { if (layer.size !== BigInt(contents.length) || layer.digest !== sha256(contents)) { throw new Error(`${role} vault copy does not match the intended plan bytes`); } - const pathStat = fs.lstatSync(absolute, { bigint: true }); + const pathStat = lstatChild(artifact); if ( pathStat.isSymbolicLink() || !pathStat.isFile() || @@ -1520,32 +1809,31 @@ function createVaultCopyFromBytes(repo, vault, contents, role) { } finally { fs.closeSync(fd); } - const gitPath = artifactGitPath(name); - verifyVaultArtifactFromFreshRoot(repo, gitPath, layer); - return { role, gitPath, layer }; + verifyVaultArtifactFromFreshRoot(repo, name, layer); + return { role, gitPath: artifactGitPath(name), layer }; } function movePathToVault(repo, sourceHandle, sourceName, vault, role) { - const source = descriptorPath(sourceHandle.fd, sourceName); - if (!lstatOptional(source)) return null; + const source = anchoredChild(sourceHandle, sourceName); + if (!lstatAnchoredOptional(source)) return null; const name = `.gitnexus-plan-${role}-${process.pid}-${randomBytes(16).toString('hex')}.bak`; - const destination = descriptorPath(vault.fd, name); - const moved = atomicMoveNoReplace( - externalDescriptorPath(sourceHandle.fd, sourceName), - externalDescriptorPath(vault.fd, name), - ); + const destination = anchoredChild(vault, name); + const moved = publishNoReplace(source, destination); if (!moved) throw new Error(`${role} preservation destination unexpectedly exists`); fs.fsyncSync(sourceHandle.fd); if (vault.fd !== sourceHandle.fd) fs.fsyncSync(vault.fd); - const sourceAfter = lstatOptional(source); - const destinationAfter = lstatOptional(destination); + const sourceAfter = lstatAnchoredOptional(source); + const destinationAfter = lstatAnchoredOptional(destination); if (sourceAfter || !destinationAfter) { throw new Error(`${role} could not be atomically moved into the Git-admin vault`); } - const opened = openVerifiedPathFile(destination, `${role} Git-admin artifact`); - const gitPath = artifactGitPath(name); - verifyVaultArtifactFromFreshRoot(repo, gitPath, opened.layer); - return { role, gitPath, layer: opened.layer, fd: opened.fd }; + const opened = openVerifiedAnchoredFile( + destination, + `${role} Git-admin artifact`, + destinationAfter, + ); + verifyVaultArtifactFromFreshRoot(repo, name, opened.layer); + return { role, gitPath: artifactGitPath(name), layer: opened.layer, fd: opened.fd }; } function formatPreservedArtifacts(artifacts) { @@ -1600,10 +1888,10 @@ export function writePlanSafely({ const finalName = components.pop(); let parentHandle; let vaultHandle; - let tempPath; + let tempRef; let tempName; let tempFd; - let finalPath; + let finalRef; let expectedTemp; let originalDestination; let priorBackup; @@ -1611,7 +1899,6 @@ export function writePlanSafely({ try { parentHandle = openPlanParent(repo, components); vaultHandle = openBackupVault(repo); - resolveAtomicMover(); const parentDevice = fs.fstatSync(parentHandle.fd, { bigint: true }).dev; const vaultDevice = fs.fstatSync(vaultHandle.fd, { bigint: true }).dev; if (parentDevice !== vaultDevice) { @@ -1622,19 +1909,11 @@ export function writePlanSafely({ testHooks?.afterParentOpen?.({ fd: parentHandle.fd, path: parentHandle.expectedPath }); validatePlanParent(parentHandle); validatePlanParent(vaultHandle); - finalPath = descriptorPath(parentHandle.fd, finalName); - originalDestination = openExistingPlanDestination(finalPath, shouldReplace); + finalRef = anchoredChild(parentHandle, finalName); + originalDestination = openExistingPlanDestination(finalRef, shouldReplace); tempName = `.gitnexus-plan-${process.pid}-${randomBytes(16).toString('hex')}.tmp`; - tempPath = descriptorPath(parentHandle.fd, tempName); - tempFd = fs.openSync( - tempPath, - fs.constants.O_RDWR | - fs.constants.O_CREAT | - fs.constants.O_EXCL | - fs.constants.O_NOFOLLOW | - (fs.constants.O_CLOEXEC ?? 0), - 0o600, - ); + tempRef = anchoredChild(parentHandle, tempName); + tempFd = createChild(tempRef, VERIFIED_CREATE_FLAGS, 0o600); writeAll(tempFd, contents); fs.fchmodSync(tempFd, 0o644); fs.fsyncSync(tempFd); @@ -1646,12 +1925,12 @@ export function writePlanSafely({ testHooks?.beforeRename?.({ fd: parentHandle.fd, path: parentHandle.expectedPath, - tempPath, + tempPath: tempRef.path, }); validatePlanParent(parentHandle); validatePlanParent(vaultHandle); validateOpenPlanDestination(originalDestination); - const tempPathStat = fs.lstatSync(tempPath, { bigint: true }); + const tempPathStat = lstatChild(tempRef); const currentTemp = hashOpenFile(tempFd, 'generated-plan temporary file'); if ( tempPathStat.isSymbolicLink() || @@ -1664,7 +1943,7 @@ export function writePlanSafely({ } if (shouldReplace) { - testHooks?.beforeBackupMove?.({ fd: parentHandle.fd, finalPath }); + testHooks?.beforeBackupMove?.({ fd: parentHandle.fd, finalPath: finalRef.path }); const originalLayer = hashOpenFile(originalDestination.fd, 'prior generated plan'); if (originalLayer.digest !== expectedDigest) { throw new Error( @@ -1673,7 +1952,7 @@ export function writePlanSafely({ } validatePlanParent(parentHandle); validateOpenPlanDestination(originalDestination); - inspectPlanDestination(finalPath, { + inspectPlanDestination(finalRef, { replace: true, expectedIdentity: originalDestination.identity, }); @@ -1691,20 +1970,20 @@ export function writePlanSafely({ ); throw new Error('Destination raced while the prior plan was moved into preservation'); } - if (lstatOptional(finalPath)) { + if (lstatAnchoredOptional(finalRef)) { throw new Error('Destination reappeared after the prior plan was preserved'); } } testHooks?.beforePublication?.({ fd: parentHandle.fd, - finalPath, - tempPath, + finalPath: finalRef.path, + tempPath: tempRef.path, replace: shouldReplace, }); validatePlanParent(parentHandle); validatePlanParent(vaultHandle); - const finalTempPathStat = fs.lstatSync(tempPath, { bigint: true }); + const finalTempPathStat = lstatChild(tempRef); const finalTemp = hashOpenFile(tempFd, 'generated-plan temporary file'); if ( finalTempPathStat.isSymbolicLink() || @@ -1715,19 +1994,25 @@ export function writePlanSafely({ ) { throw new Error('Generated-plan temporary path or content changed at publication'); } - atomicMoveNoReplace( - externalDescriptorPath(parentHandle.fd, tempName), - externalDescriptorPath(parentHandle.fd, finalName), - ); - if (lstatOptional(tempPath) || !lstatOptional(finalPath)) { + // link() reports the race itself; re-deriving that verdict from a later pair + // of stats would be both slower and weaker. + if (!publishNoReplace(tempRef, finalRef)) { throw new Error('Generated-plan publication was refused because the destination raced'); } + // link() creates a directory entry, so it needs the parent fsync that rename + // needed: the file's own bytes were fsynced through tempFd before this point, + // and this makes the name that now reaches them durable too. Skipping it is + // the step write-file-atomic omits and maildir, git and atomicwrites all + // mandate. + // + // Honest limitation: on macOS fsync is not a write barrier — the durable + // primitive there is fcntl(F_FULLFSYNC), which Node does not expose. A + // macOS plan write is therefore as durable as fsync makes it and no more. fs.fsyncSync(parentHandle.fd); - testHooks?.afterPublication?.({ fd: parentHandle.fd, finalPath }); - testHooks?.afterRename?.({ fd: parentHandle.fd, finalPath }); + testHooks?.afterPublication?.({ fd: parentHandle.fd, finalPath: finalRef.path }); validatePlanParent(parentHandle); validatePlanParent(vaultHandle); - validateCommittedPlan(finalPath, tempFd, expectedTemp, testHooks); + validateCommittedPlan(finalRef, tempFd, expectedTemp, testHooks); const receipt = { generated_plan_path: generatedPlan, bytes_written: contents.length }; if (priorBackup) receipt.prior_plan_backup_git_path = priorBackup.gitPath; return receipt; @@ -1848,6 +2133,11 @@ export function snapshotEvidence({ const headGuards = captureHeadGuards(repo); const dirty = initialDirty.records; const mutationGuards = []; + // Per-snapshot walk state: `absenceCache` owns every descriptor an absence + // anchor holds, deduplicated by repo-relative prefix and closed exactly once + // below; `guardedDirectories` keeps parent guarding to one stat per directory. + const absenceCache = new Map(); + const walkState = { absenceCache, guardedDirectories: new Set() }; try { testHooks?.afterAnchorCapture?.({ headCommit: head }); @@ -1862,7 +2152,9 @@ export function snapshotEvidence({ testHooks?.afterGitLayerLoad?.({ headCommit: head }); const globalEntries = [...dirty.values()] .filter((record) => record.path !== generatedPlan) - .map((record) => materializeRecord(repo, record, layers, mutationGuards, testHooks)); + .map((record) => + materializeRecord(repo, record, layers, mutationGuards, testHooks, walkState), + ); const citedEntries = [...normalizedCitations].sort(compareUtf8).map((repoPath) => { const status = dirty.get(repoPath) ?? { path: repoPath, @@ -1871,7 +2163,7 @@ export function snapshotEvidence({ rename_to: null, has_untracked: false, }; - const entry = materializeRecord(repo, status, layers, mutationGuards, testHooks); + const entry = materializeRecord(repo, status, layers, mutationGuards, testHooks, walkState); const present = Object.values(entry.object_kind).some((kind) => kind !== ABSENT); if (!present) entry.state = ABSENT; else if (entry.state === 'clean' && entry.object_kind.untracked !== ABSENT) { @@ -1906,21 +2198,13 @@ export function snapshotEvidence({ throw new Error(`${guard.absolute} changed before evidence materialization completed`); } } else if (guard.type === 'absence') { + // statIdentity is a strict superset of stableDirectoryIdentity on the + // same stat, so comparing both could only ever fire together. const parent = fs.fstatSync(guard.fd, { bigint: true }); - if ( - !parent.isDirectory() || - stableDirectoryIdentity(parent) !== guard.parentIdentity || - statIdentity(parent) !== guard.parentMutationIdentity - ) { + if (!parent.isDirectory() || statIdentity(parent) !== guard.parentMutationIdentity) { throw new Error(`Absence anchor changed for ${guard.repoPath}`); } - try { - fs.lstatSync(descriptorPath(guard.fd, guard.childName), { bigint: true }); - } catch (error) { - if (error?.code === 'ENOENT') continue; - throw error; - } - throw new Error(`${guard.repoPath} appeared before evidence materialization completed`); + anchoringBackend().verifyAbsentChild(guard); } } for (const guard of headGuards) verifyControlFile(guard); @@ -1955,12 +2239,10 @@ export function snapshotEvidence({ cited_path_manifest: citedEntries, }; } finally { - const closed = new Set(); - for (const guard of mutationGuards) { - if (guard.type !== 'absence' || closed.has(guard.fd)) continue; - closed.add(guard.fd); + // One entry per distinct anchored directory, so one close per descriptor. + for (const handle of absenceCache.values()) { try { - fs.closeSync(guard.fd); + fs.closeSync(handle.fd); } catch { // Preserve the primary snapshot result/error. } diff --git a/.gitattributes b/.gitattributes index 5110ebb5d..eeb1a0976 100644 --- a/.gitattributes +++ b/.gitattributes @@ -15,3 +15,15 @@ *.so binary *.dll binary *.dylib binary + +# TypeScript sources are always text for diff purposes. Git's binary +# heuristic fires when EITHER blob in a pair carries a NUL, so a source +# file that carried one on a base commit still renders as "Binary files +# differ" — with no hunks and no inline comments — long after the byte +# itself is gone from the working tree. A head-side guard cannot see +# that, by construction. This does not mark the files binary or change +# how they are stored; it only stops the heuristic from hiding a diff. +*.ts diff +*.tsx diff +*.mts diff +*.cts diff diff --git a/.github/workflows/build-tree-sitter-prebuilds.yml b/.github/workflows/build-tree-sitter-prebuilds.yml index d992c5634..2411748c5 100644 --- a/.github/workflows/build-tree-sitter-prebuilds.yml +++ b/.github/workflows/build-tree-sitter-prebuilds.yml @@ -567,7 +567,7 @@ jobs: NODE - name: Attest build provenance (SLSA) - uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 with: subject-path: 'gitnexus/vendor/tree-sitter-*/prebuilds/**/*.node' diff --git a/.github/workflows/ci-e2e.yml b/.github/workflows/ci-e2e.yml index b40ccc19f..a30371637 100644 --- a/.github/workflows/ci-e2e.yml +++ b/.github/workflows/ci-e2e.yml @@ -17,7 +17,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v3 + - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v3 id: filter with: filters: | diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 65f7f403e..be7b4d813 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -481,7 +481,29 @@ jobs: node --import tsx bench/python-scope/import-target-fingerprint.mjs --check working-directory: gitnexus + - name: Java wildcard-static route constant guards (#3110) + if: ${{ !cancelled() }} + # Build-free: named-import control vs wildcard materialization; + # fingerprints bindings and guards scaling + absolute wall time. + run: node --import tsx bench/java-wildcard-route-constants/measure.mjs --check + working-directory: gitnexus + + - name: Kotlin package-star route constant guards (#3110) + if: ${{ !cancelled() }} + # Build-free: explicit-import control vs package-star folding; + # fingerprints route facts and guards scaling + widening overhead. + run: node --import tsx bench/kotlin-star-route-constants/measure.mjs --check + working-directory: gitnexus + - name: Cross-language scope-capture fingerprint + scaling guards + # Runs even after an earlier guard fails (#2895). Every step here was + # fail-fast, so the FIRST failing --check aborted the job and every guard + # after it reported `skipped` — which reads identically to "nothing to do". + # Audited across 13 benchmark runs on #2856: the job succeeded zero times + # and the last two guards executed zero times for the life of the PR, while + # two reviews read the checks summary and saw nothing wrong. `!cancelled()` + # rather than `always()` so an explicit cancel still stops the job. + if: ${{ !cancelled() }} # Build-free: asserts emitScopeCaptures output is unchanged # (fingerprint) and stays linear (scaling < 1.5) for go/csharp/rust/php/ # ruby/cobol. Catches an O(n^2) re-regression without the worker pool. @@ -489,6 +511,7 @@ jobs: working-directory: gitnexus - name: Callable-value-flow target-index guards (#2693) + if: ${{ !cancelled() }} # Build-free: asserts buildGraphTargetIndex resolves an unchanged target # set (fingerprint), stays linear in def count, and that the #2693 # widened gate — which now considers VALUE bindings, a population that @@ -500,7 +523,44 @@ jobs: run: node --import tsx bench/callable-value-flow/measure.mjs --check working-directory: gitnexus + - name: Java Lombok accessor synthesis guards (#2885) + if: ${{ !cancelled() }} + # Build-free: no-Lombok vs Lombok-heavy corpora; fingerprint over + # synthetic Method ids; scaling + widening overhead budgets. + run: node --import tsx bench/java-lombok-synthesis/measure.mjs --check + working-directory: gitnexus + + - name: Kotlin JVM accessor synthesis guards (#2885) + if: ${{ !cancelled() }} + # Build-free: no-property vs data-class corpora; fingerprint over + # synthetic Method ids; scaling + widening overhead budgets. + run: node --import tsx bench/kotlin-jvm-accessors/measure.mjs --check + working-directory: gitnexus + + - name: Kotlin Spring config-consumer capture guards (#2412) + if: ${{ !cancelled() }} + # Build-free: explicit-import control vs wildcard-import feature path; + # fingerprints @Value / @ConfigurationProperties facts and guards scaling + # + widening overhead. The parity check is the regression gate: each file + # declares a sibling nested type named `Value`, which must not suppress + # the imported Spring annotation (file-wide shadowing dropped 2 of every + # 3 facts on this corpus). + run: node --import tsx bench/spring-config-bindings/measure.mjs --check + working-directory: gitnexus + + - name: Re-export closure scaling guards (#2864) + # Build-free: asserts buildReexportClosures stays linear in chain depth + # and within an absolute ceiling on a wide package corpus. #2864 changed + # this pass's input class from TypeScript barrels (a handful of shallow + # edges) to every module-level Python `from m import x`, which is where + # its two quadratic corners became reachable. The depth arm specifically + # guards MAX_VIA_LENGTH — the bound that was removed once already, in + # fc919ad6, and stayed invisible for as long as the input was shallow. + run: node --import tsx bench/finalize-reexport/measure.mjs --check + working-directory: gitnexus + - name: C++ qualified-namespace resolution guards (#2788) + if: ${{ !cancelled() }} # Build-free: asserts resolveCppQualifiedNamespaceMember resolves an # unchanged symbol set (fingerprint) and that per-call-site cost stays # independent of corpus size. Rationale and history: see the header of @@ -508,18 +568,118 @@ jobs: run: node --import tsx bench/cpp-qualified-ns/measure.mjs --check working-directory: gitnexus - - name: Kotlin import-resolution identity + scaling guards - # Build-free: asserts resolveKotlinImportTarget resolves an unchanged - # file set (fingerprint, in both file-set iteration orders — every - # tie-break in that resolver is expressed only through iteration order) - # and that per-import cost stays independent of workspace size. The - # pre-index implementation scores 3.737 on this corpus against 0.99 for - # the index, so the gate separates them by a wide margin. Rationale and - # history: see the header of bench/kotlin-import-target/measure.mjs. + - name: Import-target resolution guards (every registered language, #2877–#2909, PR #2911) + if: ${{ !cancelled() }} + # Build-free: runs EVERY import-target resolver registered in + # SCOPE_RESOLVERS — plus C# a second time WITH csproj configs, over the + # identical corpus, because the no-csproj arm returns before it can + # reach the leg #2902 indexed. One arm per registered language over ONE + # shared corpus, and no registered language ungated. That is enforced, + # not enumerated: measure.mjs derives its list from a LANG_REGISTRY + # table and its --check inventory arm reconciles that table against + # SCOPE_RESOLVERS in both directions, so a language roster typed out + # here would only be a second copy that can go stale — this one did. + # A C/C++ #include is an import site for this purpose and is gated like + # every other registered language (its headers arrive through + # resolutionConfig rather than allFilePaths, which is the one structural + # difference — see `newPass`). + # + # Asserts each returns an unchanged target set (a fingerprint per + # language AND per arm), that per-import cost stays independent of + # corpus size AND of path depth, that the absolute small-arm cost holds + # — a constant-factor regression that grows both scale arms equally + # passes every ratio — and that the per-pass index eight of them retain + # stays within an absolute byte ceiling. The corpus SHAPE is asserted + # too: a fingerprint alone cannot tell a legitimate resolution change + # from a corpus quietly shrunk below the size the timing arms need. + # + # Several arms exist because an arm that stops MEASURING otherwise + # passes. The heap arms drive real resolvers and carry a FLOOR as well + # as a ceiling: when buildSuffixIndex's suffix maps went lazy, four arms + # that called the builder directly read 0 B, and 0 B is under every + # ceiling. EVERY budget is checked for PRESENCE first, timing and heap + # alike, because `got > undefined` is false and `got < ceiling * + # undefined` is false too, so deleting a budget key deleted its gate — + # and the two heap scalars gate all eight heap arms at once. The heap + # arm's own corpus shape (its two file counts, its path depth and the + # probe it resolves) is asserted by the same loop as the timing arms, + # because those four decide WHAT it measures. And an inventory arm + # reconciles the bench's language table against SCOPE_RESOLVERS itself, + # so a newly registered resolver cannot ship ungated the way JavaScript + # did. + # + # The resolvers gated first were added as their own O(imports × files) + # scans were indexed away (Ruby rebuilt a suffix index per `require`; + # COBOL scanned twice per `COPY`), and the same corpus shape scores >3.3 + # against those pre-fix implementations. The rest were ungated until + # this PR, which is not a theoretical gap: PR #2911 found JavaScript + # reaching suffixResolve with no index at all — 25 972 µs per import at + # 8000 files, protected only by unit tests. This step is what stops the + # next one shipping. + # + # SCOPE: "independent of corpus size" holds for UNIQUE-LEAF layouts, + # where no two directories share a last segment and no two files share a + # basename — which is what the small/large/deep arms are, and where + # every index bucket holds exactly one entry. The `collide` arm runs the + # identical workload on the layout these languages are actually written + # in (svcN/internal, SrcN/Models, a repeated basename per package, four + # SPM modules instead of fifty); there the bucket grows with the file + # count by construction and go, csharp, dart, java, swift and c/cpp + # legitimately score 2.1–3.9, so that arm carries its own per-language + # budget. It is a scope limit, not a regression — the indexed code is + # still faster on that shape than the pre-change scan. Rust is the one + # language whose collide arm is NOT a shared-leaf layout: it probes + # candidate paths and is provably flat in the file count, so its arm is + # a deep module tree that varies `::` segment count instead — the axis + # its cost actually has. + # + # --expose-gc enables the retained-heap arm; --check REFUSES to run + # without it rather than passing with the memory gate silently skipped. + # ~44–45 s, which is essentially unchanged from the ~46 s it cost + # before: the timing phase did fall from 39.8 s to 28.7 s when the + # min-of-N estimator became per-language, but the inventory arm's one + # dynamic import (pipeline/registry.ts pulls in every registered + # provider) costs 6–10 s depending on the box and consumes almost all of + # that. Report mode, which does not load the registry, is the mode that + # got faster: ~33–35 s. Kept as-is because this job runs minutes clear + # of the sharded coverage job that gates the merge, so the seconds buy + # no merge latency — see COST in the bench header. The ts + # family (javascript/typescript/vue) is still the largest block, 8.8 s, + # because suffixResolve probes ~39 extensions per path part on a miss. + # If this ever has to shrink, drop collide/collide_large for typescript + # and vue (−3.9 s) — the only cut that removes near-duplicate work + # rather than coverage. N is 15 (matching bench/cfg) for every language + # whose cheapest arm is under 5 ms, because depth_ratio divides two + # sub-3 ms numbers and at 5 or 7 it tripped its own budget roughly 1 run + # in 20; the six languages whose cheapest arm is 20-28 ms drop to 7-8, + # where the measured overshoot is at most 6.3%. The estimator was fixed + # rather than the budget widened; distributions in _arms_note. + # The Kotlin arm here is a second corpus, not a replacement for the + # kotlin-import-target bench below, which carries declared-package + # correctness probes this shared corpus does not. + # It sits with the other resolver-index guards rather than at the end of + # the job: parking a new gate last is not safety, it is the slot least + # likely to execute (#2895 measured the last two guards running zero + # times in 13 runs). #2899 landed the `if: ${{ !cancelled() }}` below, + # which is what makes position irrelevant — a failing step no longer + # aborts the ones after it. + # Rationale, budgets and the measured blind spot: see the header of + # measure.mjs and _blind_spot in baselines.json. + run: node --expose-gc --import tsx bench/import-target/measure.mjs --check + working-directory: gitnexus + + - name: Kotlin declared-package import correctness + scaling guards + if: ${{ !cancelled() }} + # Build-free: fingerprints declared-package evidence, external decoy + # rejection, top-level/member/wildcard imports, overload sets and root + # packages, then guards one package-index build per workspace against + # file-count and path-depth scaling. Rationale and history: see the + # header of bench/kotlin-import-target/measure.mjs. run: node --import tsx bench/kotlin-import-target/measure.mjs --check working-directory: gitnexus - name: Receiver-resolution drop guards + if: ${{ !cancelled() }} # NOT build-free: this one runs the real pipeline, so it needs dist/ # (the setup action above builds). ~2m15s. # @@ -545,6 +705,7 @@ jobs: working-directory: gitnexus - name: Scope-emission guards (#2699) + if: ${{ !cancelled() }} # Build-free: asserts the JS/TS scope set is unchanged. Block scopes are # what make `let`/`const` in sibling blocks distinct bindings, but a # scope per `statement_block` triples the count and deepens every @@ -557,6 +718,7 @@ jobs: working-directory: gitnexus - name: CFG construction time / disk / memory guards (#2081 M1) + if: ${{ !cancelled() }} # Build-free: asserts collectFunctionCfgs output is unchanged # (fingerprint) and that wall-time, cfgSideChannel disk bytes, AND # retained heap all stay sub-quadratic for the straight-line / @@ -567,6 +729,7 @@ jobs: working-directory: gitnexus - name: Emit-persistence throughput / byte-identity guards (#2203) + if: ${{ !cancelled() }} # Build-free: asserts streamAllCSVsToDisk output is byte-identical # (order-independent CSV-line fingerprint — the #2203 U2/U3 emit # optimisations must not change graph content) and that emit wall-time @@ -576,6 +739,7 @@ jobs: working-directory: gitnexus - name: Streaming PDG-emit byte-identity / bounded-RSS guards (#2202) + if: ${{ !cancelled() }} # Build-free: asserts the streaming PdgEmitSink emits a CSV row SET # byte-identical to the whole-graph streamAllCSVsToDisk emit, AND that # the in-memory graph retains zero BasicBlock nodes (the O(chunk) peak-RSS @@ -585,19 +749,23 @@ jobs: working-directory: gitnexus - name: Cross-language pipeline benchmarks (GITNEXUS_BENCH, serial) - # cpp-adl-benchmark.test.ts is not a `*-pipeline-benchmark.test.ts` but - # belongs here for the same reason: it is skipIf-gated on GITNEXUS_BENCH, - # so it had never run in CI and the PR #1990 ADL emit-scaling guard it - # holds was dead. ~45s of test time. + if: ${{ !cancelled() }} + # cpp-adl-benchmark.test.ts and csharp-razor-view-components-benchmark.test.ts + # are not `*-pipeline-benchmark.test.ts` files but belong here for the + # same reason: they are skipIf-gated on GITNEXUS_BENCH, so the scaling + # guards they hold never run in the main coverage job. env: GITNEXUS_BENCH: '1' run: >- npx vitest run --no-file-parallelism test/integration/cobol-pipeline-benchmark.test.ts test/integration/csharp-pipeline-benchmark.test.ts + test/integration/csharp-razor-view-components-benchmark.test.ts test/integration/cpp-adl-benchmark.test.ts + test/integration/data-route-table-benchmark.test.ts test/integration/instance-ownership-pipeline-benchmark.test.ts test/integration/spring-bean-resource-benchmark.test.ts + test/integration/spring-dynamic-lookup-benchmark.test.ts test/integration/rust-pipeline-benchmark.test.ts test/integration/php-pipeline-benchmark.test.ts test/integration/ruby-pipeline-benchmark.test.ts diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index d47d25a78..a768b69e7 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -48,7 +48,7 @@ jobs: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 + uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 with: languages: ${{ matrix.language }} queries: security-and-quality @@ -71,8 +71,14 @@ jobs: # deliberately contain use-before-init / unused-variable shapes). - '**/test/fixtures/**' - '**/test/**/fixtures/**' + # GET /api/grep intentionally builds RegExp from the query string + # (literal=1 escapes). ReDoS is handled by worker terminate() — + # see SECURITY.md. Inline codeql[] comments do not clear the + # GitHub PR CodeQL gate, so this file is excluded to avoid + # re-filing js/regex-injection on every push of the same line. + - 'gitnexus/src/server/grep-params.ts' - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 + uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 with: category: '/language:${{ matrix.language }}' diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 849291275..dac6a7398 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -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 @@ -256,7 +256,7 @@ jobs: # pulling from either GHCR or Docker Hub see the same provenance. - name: Generate build provenance attestation (GHCR) if: ${{ github.event_name != 'pull_request' && !inputs.dry_run }} - uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 with: subject-name: ghcr.io/${{ github.repository_owner }}/${{ matrix.image.slug }} subject-digest: ${{ steps.build.outputs.digest }} @@ -264,7 +264,7 @@ jobs: - name: Generate build provenance attestation (Docker Hub) if: ${{ github.event_name != 'pull_request' && !inputs.dry_run }} - uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 with: subject-name: docker.io/akonlabs/${{ matrix.image.slug }} subject-digest: ${{ steps.build.outputs.digest }} diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index f511d4d51..f6e54108d 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -53,6 +53,6 @@ jobs: retention-days: 5 - name: Upload to Security tab - uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 + uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 with: sarif_file: results.sarif diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml index ecb394c99..699289656 100644 --- a/.github/workflows/trivy.yml +++ b/.github/workflows/trivy.yml @@ -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@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 + uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 with: sarif_file: trivy-${{ matrix.image.name }}.sarif category: trivy-${{ matrix.image.name }} diff --git a/.github/workflows/workflow-lint.yml b/.github/workflows/workflow-lint.yml index e013a0bf6..9c3b45ab6 100644 --- a/.github/workflows/workflow-lint.yml +++ b/.github/workflows/workflow-lint.yml @@ -76,7 +76,7 @@ jobs: continue-on-error: true - name: Upload SARIF - uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 + uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 with: sarif_file: zizmor.sarif category: zizmor diff --git a/.gitignore b/.gitignore index e16544f71..3d125d475 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,8 @@ npm-debug.log* # Testing coverage/ +.tmp-test/ +gitnexus/.tmp-test/ # Misc *.local diff --git a/.gitleaksignore b/.gitleaksignore index c713b712a..243cd4410 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -1,2 +1,4 @@ # Deleted README placeholder from PR #2458; no credential was present. c9fdab17f25ebaf332fba6e6ba55ee328f20fe66:README.md:curl-auth-header:348 +# Synthetic Kotlin Actuator fixture value from PR #3107; no credential was present. +3951079300a18b14e79f5b5f5dd778ae19ced6e3:gitnexus/test/integration/spring-actuator-kotlin-runtime-pipeline.test.ts:generic-api-key:8 diff --git a/AGENTS.md b/AGENTS.md index f4fcef0af..251f410c1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,18 +111,17 @@ mirror. `gitnexus/test/unit/shipped-skills-sync.test.ts` guards the copies. Toke # GitNexus — Code Intelligence -This project is indexed by GitNexus as **GitNexus** (248612 symbols, 565510 relationships, 918 execution flows). Use GitNexus graph tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **GitNexus** (248612 symbols, 565510 relationships, 918 execution flows). -> Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? Bootstrap with `npx`, `bunx`, or `pnpm dlx` — e.g. `bunx gitnexus@latest analyze` (npm 11 npx crash; #1939). +> Index stale? Run `node .gitnexus/run.cjs analyze --index-only` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? Bootstrap with `npx`, `bunx`, or `pnpm dlx` — e.g. `bunx gitnexus@latest analyze` (npm 11 npx crash; #1939). ## Always Do - **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: ` — 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 --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). 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 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 on HIGH/CRITICAL `risk` pre-edit; never use `riskSharedAxes` to waive a HIGH/CRITICAL `risk` warning. Compare File/symbol: MCP File omits axes; Graph-RAG expands File. - **MUST treat `risk: UNKNOWN` as unresolved, not as low.** An empty caller set is not evidence the symbol is unused — it can also mean the callers are not resolvable by the index (plain-object property access, dynamic dispatch, cross-language calls). `impact` pairs `UNKNOWN` with a `riskNote` saying so. Confirm with a text search before treating the symbol as safe to change or delete; do not proceed on the strength of a zero. -- When exploring unfamiliar code, use `query({search_query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. -- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `context({name: "symbolName"})`. +- **MUST use `query({search_query: "concept"})` for concepts/flows, `context({name: "symbolName"})` for a named symbol, or `impact` for blast radius, on read-only callers, dependencies, imports, or execution flow.** Graph first; text search only for empty/`UNKNOWN`/literals. - For security review, `explain({target: "fileOrSymbol"})` lists taint findings (source→sink flows; needs `analyze --pdg`). - For control/data dependence, `pdg_query({mode: "controls", target: "fileOrSymbol"})` answers "under what condition does X run?" (CDG, incl. guard clauses) and `pdg_query({mode: "flows", target, variable})` traces "where does variable Y flow?" (REACHING_DEF). `--pdg` layer. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 03e3730b3..b1076d1d5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -98,7 +98,7 @@ scan → structure → [springConfig, markdown, cobol] → parse → [routes, to | `markdown` | `markdown.ts` | `structure` | Section nodes, cross-link edges from .md/.mdx | | `cobol` | `cobol.ts` | `structure` | COBOL program/paragraph/section nodes (regex, no tree-sitter) | | `parse` | `parse.ts` + `parse-impl.ts` | `structure`, `markdown`, `cobol` | Symbol nodes, IMPORTS/CALLS/EXTENDS edges, extracted routes/tools/ORM queries | -| `routes` | `routes.ts` | `parse` | Route nodes + HANDLES_ROUTE edges (Next.js, Expo, PHP, decorators, and JS/TS dispatch guards — see below) | +| `routes` | `routes.ts` | `parse` | Route nodes + HANDLES_ROUTE edges (Next.js, Expo, PHP, decorators, and JS/TS static route sources — see below) | | `tools` | `tools.ts` | `parse` | Tool nodes + HANDLES_TOOL edges | | `orm` | `orm.ts` | `parse` | QUERIES edges (Prisma, Supabase) | | `crossFile` | `cross-file.ts` + `cross-file-impl.ts` | `parse`, `routes`, `tools`, `orm` | Cross-file type propagation in topological import order | @@ -108,7 +108,7 @@ scan → structure → [springConfig, markdown, cobol] → parse → [routes, to | `pruneLocalSymbols` | `prune-local-symbols.ts` | `scopeResolution` | Drops inert block-local `Const`/`Variable`/`Static` nodes (only a `File→DEFINES` edge) post-resolution | | `mro` | `mro.ts` | `crossFile`, `scopeResolution`, `pruneLocalSymbols`, `structure` | METHOD_OVERRIDES + METHOD_IMPLEMENTS edges | | `springAopInheritance` | `spring-aop.ts` | `springAop`, `mro` | Propagates declarative behavior through class/interface inheritance decisions | -| `di` | `di.ts` | `mro` | INJECTS edges from consumer Classes or factory Methods to provider Classes/declaration CodeElements (framework-neutral DI resolution; per-language matchers registered in `di-extractors/`) | +| `di` | `di.ts` | `mro` | INJECTS edges from consumer Classes, factory Methods, or AST-captured programmatic lookup callables to provider Classes/declaration CodeElements (framework-neutral DI resolution; per-language matchers registered in `di-extractors/`) | | `communities` | `communities.ts` | `mro`, `pruneLocalSymbols`, `structure` | Community nodes + MEMBER_OF edges (Leiden algorithm) | | `processes` | `processes.ts` | `communities`, `routes`, `tools`, `pruneLocalSymbols`, `structure` | Process nodes + STEP_IN_PROCESS edges | @@ -174,7 +174,7 @@ converging on the routes phase's `(method, url)` registry: | Filesystem convention | path → URL, no parsing | Next.js `app/`, Expo, PHP | | Single-file framework route | `isRouteFile` + worker extraction | Laravel `routes/*.php` | | Cross-file framework route | `discoverRootRouteFiles` + `extractRoutes` | Django `urlpatterns` | -| AST-level route in a normal file | `extractDecoratorRoutes` | Spring, FastAPI, NestJS, **JS/TS dispatch guards** | +| 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 @@ -185,6 +185,15 @@ handler resolution are shared with decorator routes, and `ExtractedDecoratorRoute.source` carries the provenance difference through to the `HANDLES_ROUTE` edge. +JS/TS data route tables share that transport when a route-named array contains +direct object literals with static `path`, `method`, and `handler` fields and a +same-scope `for...of` dispatcher positively compares the path and method before +directly invoking the handler. Dynamic values, computed keys, spreads, +inline/called handlers, unknown verbs, and ambiguous handler bindings are +suppressed. Bare import aliases and single-level member handlers are attributed +only through declared import and owner provenance; an unproven receiver never +falls back to a global name guess. + That extractor is deliberately **precision-weighted**: `route_map` presents its output as fact, so a `startsWith` namespace test, a bare `pathname === '/'` without a verb, and any regex it cannot translate exactly are all dropped rather @@ -277,6 +286,12 @@ The solver is flow-insensitive but bounded: dependency-indexed work items rerun Property-key dispatch remains a separate conservative fallback. Its per-key fan-out cap is 32; capped keys synthesize no partial calls and are reported at warning level with language, skipped-key count, dropped key names (bounded), and cap; the count also travels in `RunScopeResolutionStats.propertyDispatchSkippedKeys`. +Interface-dispatch fan-out walks the subtype closure of the receiver's interface and is **generic-instantiation aware** (#2912): a call through `IValidator` must not reach an implementor of `IValidator`, which shares its declaration and therefore its subtype list. Each heritage clause's arguments reach resolution by one of three routes — read off the `@reference.inherits` anchor's own spelling where that anchor spans the whole base (most languages, no query change), through the `@reference.type-arguments` sub-tag where the anchor is the bare name and moving it would renumber inheritance edge ids (Rust `impl T for S`, Dart `extends`), or on a heritage MARKER payload for clauses that never become reference sites (Dart `implements`/`with`). Whichever pass emits the edge records the pair through one sink: `preEmitInheritanceEdges` for heritage clauses, `ScopeResolver.emitHeritageEdges` for the rest. + +The walk then carries a substitution: a subtype's own type parameters bind to the receiver's arguments, so `class Wrapper : IValidator` stays reachable from every instantiation while `class IntValidator : IValidator` is pruned from the `string` one. Receiver arguments come from the declared type (Case 4), a class-level field's declared type (Case 6), or — for a compound receiver such as `this._repo` — the spelling the compound fold typed that position from, reported back through `recordReceiverType` and accepted only when it names the class the fold returned. + +The filter prunes only on positive evidence: an unknown instantiation on either side, an argument list whose arity does not line up, a name that may be a type variable the language's captures never recorded, or an unresolved spelling whose simple name matches all keep the target. A type parameter of the declaration ENCLOSING either side is recognised as such and never compared — `void Run(IValidator v)` writes a receiver with no known instantiation, so it keeps the unfiltered fan-out. That recognition is what generic METHODS now carry `@declaration.type-parameters` for in C#, Java and Kotlin (TypeScript already did): without it an unbounded `T` grounds to nothing and a bounded one grounds to its BOUND, and both compare unequal to an implementor's concrete argument. Languages that capture neither type arguments nor type parameters therefore emit exactly the pre-#2912 fan-out. The fan-out cap (32, `GITNEXUS_MAX_INTERFACE_DISPATCH_FANOUT`) and its skipped-target reporting are unchanged and apply after filtering. Note the fan-out itself still fires only for a receiver whose folded type is an `Interface` symbol, so a Rust `Trait` or a Dart abstract `Class` receiver emits no secondary targets to filter in the first place. + Standalone (regex-based) providers such as COBOL participate via `ScopeResolver.scopeResolutionEdgeMode: 'callable-flow-only'`: `runScopeResolution` runs for them, but every ordinary emission path — heritage, interface implementations, receiver-bound, free-call fallback, reference/import edges, post-resolution hooks — is gated off, so their legacy phase (e.g. `cobolPhase`) remains the sole owner of structural edges and the callable solver's `CALLS` are purely additive. A callable-flow-only provider whose files emitted no callable facts exits early, before finalize, keeping the opt-in proportional to source scanning. ### Receiver chains and the drop census (#2766) @@ -388,6 +403,7 @@ Each language implements `LanguageProvider` (`language-provider.ts`). Key fields | `typeConfig` | Type annotation extraction rules | | `mroStrategy` | `first-wins` / `c3` / `none` | | `descriptionExtractor` | Optional hook returning a symbol's doc-comment text as its `description`; feeds the embedding metadata header so doc-only terms are semantically searchable (issue #2270). Most languages register `createLeadingDocDescriptionExtractor` (shared, language-neutral; per-language comment/wrapper config passed at the call site) | +| `definitionPropertiesExtractor` | Optional language-owned hook for structured, clone-safe definition metadata. Shared ingestion persists these properties opaquely; the owning provider supplies the extraction semantics. | 16 providers in `languages/index.ts` via `satisfies Record` — missing a language is a compile error. diff --git a/CLAUDE.md b/CLAUDE.md index 8382c69ed..069163232 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,18 +62,17 @@ See the `` block in **[AGENTS.m # GitNexus — Code Intelligence -This project is indexed by GitNexus as **GitNexus** (248612 symbols, 565510 relationships, 918 execution flows). Use GitNexus graph tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **GitNexus** (248612 symbols, 565510 relationships, 918 execution flows). -> Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? Bootstrap with `npx`, `bunx`, or `pnpm dlx` — e.g. `bunx gitnexus@latest analyze` (npm 11 npx crash; #1939). +> Index stale? Run `node .gitnexus/run.cjs analyze --index-only` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? Bootstrap with `npx`, `bunx`, or `pnpm dlx` — e.g. `bunx gitnexus@latest analyze` (npm 11 npx crash; #1939). ## Always Do - **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: ` — 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 --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). 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 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 on HIGH/CRITICAL `risk` pre-edit; never use `riskSharedAxes` to waive a HIGH/CRITICAL `risk` warning. Compare File/symbol: MCP File omits axes; Graph-RAG expands File. - **MUST treat `risk: UNKNOWN` as unresolved, not as low.** An empty caller set is not evidence the symbol is unused — it can also mean the callers are not resolvable by the index (plain-object property access, dynamic dispatch, cross-language calls). `impact` pairs `UNKNOWN` with a `riskNote` saying so. Confirm with a text search before treating the symbol as safe to change or delete; do not proceed on the strength of a zero. -- When exploring unfamiliar code, use `query({search_query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. -- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `context({name: "symbolName"})`. +- **MUST use `query({search_query: "concept"})` for concepts/flows, `context({name: "symbolName"})` for a named symbol, or `impact` for blast radius, on read-only callers, dependencies, imports, or execution flow.** Graph first; text search only for empty/`UNKNOWN`/literals. - For security review, `explain({target: "fileOrSymbol"})` lists taint findings (source→sink flows; needs `analyze --pdg`). - For control/data dependence, `pdg_query({mode: "controls", target: "fileOrSymbol"})` answers "under what condition does X run?" (CDG, incl. guard clauses) and `pdg_query({mode: "flows", target, variable})` traces "where does variable Y flow?" (REACHING_DEF). `--pdg` layer. diff --git a/Dockerfile.cli b/Dockerfile.cli index b42c22dad..1488bee54 100644 --- a/Dockerfile.cli +++ b/Dockerfile.cli @@ -51,8 +51,9 @@ RUN npm run postinstall --prefix gitnexus # node:22-bookworm-slim FROM node:22-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e AS runtime -# curl for the healthcheck; git for cloning; ca-certificates for TLS verification. -RUN apt-get update && apt-get install -y --no-install-recommends curl git ca-certificates && rm -rf /var/lib/apt/lists/* \ +# curl for the healthcheck; git for cloning; procps for watch process identity; +# ca-certificates for TLS verification. +RUN apt-get update && apt-get install -y --no-install-recommends curl git procps ca-certificates && rm -rf /var/lib/apt/lists/* \ && rm -rf /usr/local/lib/node_modules/npm \ && rm -rf /usr/local/lib/node_modules/corepack \ && rm -f /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack diff --git a/GUARDRAILS.md b/GUARDRAILS.md index e157ade1e..f34cf79d5 100644 --- a/GUARDRAILS.md +++ b/GUARDRAILS.md @@ -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" diff --git a/README.md b/README.md index d6534f658..0e0e2f616 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# GitNexus +# GitNexus (Akon Labs) **⚠️ Important Notice:** GitNexus has NO official cryptocurrency, token, or coin. Any token/coin using the GitNexus name on Pump.fun or any other platform is **not affiliated with, endorsed by, or created by** this project or its maintainers. Do not purchase any cryptocurrency claiming association with GitNexus. @@ -179,7 +179,7 @@ flowchart TB | `group_list` | List configured repository groups | | `group_sync` | Rebuild a group's Contract Registry and cross-repo links | -> Per-repo tools take an optional `repo` parameter (omit it when only one repo is indexed) and an optional `branch` for indexes pinned with `gitnexus analyze --branch`. Omitting `branch` queries the workspace index, which follows your checked-out working tree — switching branches and re-running `gitnexus analyze` updates it incrementally. `explain` and `pdg_query` need an index built with `gitnexus analyze --pdg`. +> Per-repo read-only tools take an optional `repo` parameter. Omit it when only one repo is indexed, an MCP default is configured, or the GitNexus process cwd is inside a registered path without crossing into an unindexed nested Git checkout; otherwise pass it explicitly. Mutating tools require `repo` when multiple repos are indexed and no MCP default exists. Per-repo tools also take an optional `branch` for indexes pinned with `gitnexus analyze --branch`. Omitting `branch` queries the workspace index, which follows your checked-out working tree — switching branches and re-running `gitnexus analyze` updates it incrementally. `explain` and `pdg_query` need an index built with `gitnexus analyze --pdg`. ### Resources for instant context @@ -384,6 +384,7 @@ Everyday commands: ```bash gitnexus setup # Configure MCP for detected editors (one-time; -c to select) gitnexus analyze [path] # Index a repository (or update a stale index) +gitnexus analyze [path] --watch # Watch local files and serialize incremental refreshes gitnexus mcp # Start MCP server (stdio) — serves all indexed repos gitnexus serve # Start local HTTP server (multi-repo) for web UI connection gitnexus eval-server # Start lightweight evaluation HTTP tools (loopback by default) @@ -396,6 +397,28 @@ gitnexus uninstall # Preview removal of GitNexus MCP/skills/hooks You can also query the graph directly from the terminal — `gitnexus query`, `context`, `impact`, `trace`, `cypher`, `detect-changes`, and `check` mirror the MCP tools of the same names, and `gitnexus doctor` prints runtime platform capabilities. +`gitnexus analyze --watch` requires a Git repository. It runs one initial +analysis, then debounces scanner-admitted working-tree changes for 300 ms by +default and applies serialized incremental refreshes. Events arriving during a +refresh remain queued, and retryable failures retain the same batch with bounded +backoff. Invalid `.gitnexusrc` or ignore-file reloads pause ordinary refreshes +until the control file is fixed. Stop the watcher with Ctrl+C. + +Watch mode accepts `--debounce`, `--workers`, `--worker-timeout`, +`--max-file-size`, `--branch`, `--pdg`, `--name`, `--allow-duplicate-name`, and +`--verbose`. Explicit one-shot options such as `--force`, `--repair-fts`, +embedding flags, `--skills`, `--self-commit`, `--index-only`, and `--skip-git` +are rejected. Unsupported defaults from `.gitnexusrc` are ignored with a +warning rather than making an otherwise valid repository unwatchable. + +POSIX requests clone-first copy-and-swap publication when the live index has no +orphan sidecars. Windows and sidecar fallback runs update in place: failures +known to occur before writes are retried, while a failure that may have mutated +the live index stops the watcher. Watch mode does not pull remotes. Running MCP +and `serve` processes reopen a newly published index automatically; MCP observes +the replacement on its next tool call, typically within five seconds, so no +restart is required. +
Authenticated eval-server binding @@ -426,10 +449,13 @@ gitnexus analyze --verbose # Log skipped files when parsers are unavailabl gitnexus analyze --worker-timeout 60 # Increase worker idle timeout for slow parses gitnexus analyze --workers # Parse worker pool size (>=1; default: cores-1, capped at 16, # auto-sized to the repo). 0 is rejected — there is no sequential mode. +gitnexus analyze --spring-actuator ./actuator # Enrich with local Spring Boot Actuator JSON snapshots gitnexus analyze --wal-checkpoint-threshold 67108864 # LadybugDB WAL auto-checkpoint threshold in bytes # (default 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB) ``` +`--spring-actuator` is explicitly opt-in and accepts either a JSON bundle keyed by `mappings`, `beans`, `conditions`, `configprops`, and/or `env`, or a directory containing endpoint-named JSON files. It confirms matching static nodes and adds conservative runtime-only routes, beans, and property keys. The configured input is excluded from source scanning; only normalized repository-relative exclusions are retained for future scans, never absolute paths. Env/configprops values, origins, condition messages, and source names are never persisted or printed. Because snapshots are external runtime state, an enabled run always rebuilds; the first later run without the option rebuilds once to remove runtime evidence. The same path can be set as `springActuator` in `.gitnexusrc`. + If `analyze` reports a worker parse timeout on a large or unusual repository, it keeps running and falls back safely. To give slow worker jobs more time, use `--worker-timeout 60` or set `GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=60000`. For very large files, `GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES` controls the worker job byte budget. **Embeddings node limit** — `gitnexus analyze --embeddings` generates semantic search vectors with a default 50,000-node safety cap to protect memory on large repositories: @@ -444,6 +470,46 @@ If embeddings are skipped on a large repository, the indexed graph likely exceed
+
+Keep remote repositories indexed with gitnexus auto-sync + +`gitnexus auto-sync` clones or pulls configured repositories, analyzes new commits, and optionally syncs their group. It runs once immediately, then repeats on the configured interval. It runs in the foreground; use your process manager if it must survive a shell session. `gitnexus watch` is reserved and prints this split; it does not start auto-sync or local file watching. + +```bash +# 1. Create the config once. It never overwrites an existing file. +gitnexus auto-sync init + +# 2. Edit $GITNEXUS_HOME/watch_config.yml, then start it. +gitnexus auto-sync start # `gitnexus auto-sync` is equivalent +gitnexus auto-sync status +gitnexus auto-sync restart # Required after config changes +gitnexus auto-sync stop +gitnexus auto-sync reset # Clear failure state; leaves clones and indexes intact +``` + +`GITNEXUS_HOME` defaults to `~/.gitnexus`. A minimal configuration: + +```yaml +sync_interval_minutes: 10 +analyze_timeout: 5m +projects: + - local_path: /absolute/path/to/clones + branches: [main, master] + overwrite_local_changes: false + remote_urls: + - git@github.com:owner/repo.git +``` + +- `sync_interval_minutes` must be at least `5`; `local_path` must be an absolute path. Clones are stored below it as `host/namespace/repo`. +- Remote URLs must use SSH SCP form and are limited to GitHub, GitLab, or Gitee. +- `branches` are tried in order. The legacy `branch` field is supported, but do not set both. +- Analysis runs in an isolated worker; `analyze_timeout` defaults to, and cannot exceed, half of `sync_interval_minutes`. Timeout and `auto-sync stop` request safe cancellation; a worker in native work exits after reaching a JS-visible safe point. Until then, auto-sync reports `cancelling` or `stopping` and retains ownership so another auto-sync cannot take over, for up to 5 seconds — after that the parent stops waiting and leaves the worker to exit on its own rather than killing it mid-write. This behavior is the same on macOS and Windows. `overwrite_local_changes` defaults to `false`, so a dirty local clone is skipped rather than overwritten; setting it to `true` also deletes untracked files in the clone, while keeping ignored paths. +- Add `group_name` only after creating that group with `gitnexus group create `. Partial clone output is isolated and removed after 14 days. + +See the [full auto-sync configuration and runtime reference](gitnexus/README.md#gitnexus-auto-sync) for concurrency, timeouts, failure thresholds, and runtime files. + +
+
Repository groups (multi-repo / monorepo service tracking) @@ -477,6 +543,7 @@ Commit a `.gitnexusrc` JSON file at the repo root to preconfigure recurring `ana "skipContextFiles": true, // alias of skipAgentsMd: keep your own AGENTS.md/CLAUDE.md "skipSkills": true, // don't install standard skill files under .claude/skills/ and .agents/skills/ "embeddings": true, // generate embeddings by default + "springActuator": "./actuator", // optional local runtime snapshot directory or bundle "workerTimeout": 60, } ``` @@ -491,7 +558,7 @@ Notes: - The default branch is resolved as: `--default-branch` > `.gitnexusrc` `defaultBranch`/`branch` > auto-detected `origin/HEAD` > `main`. - `skipContextFiles` / `skipAiContext` are aliases for `skipAgentsMd` — they skip the `AGENTS.md` / `CLAUDE.md` block only. They do **not** imply `skipSkills`. `indexOnly` is the stronger option that skips all file injection. -- Supported keys: `defaultBranch` (`branch`), `skipAgentsMd` (`skipContextFiles`, `skipAiContext`), `skipSkills`, `indexOnly`, `stats`/`noStats`, `embeddings`, `dropEmbeddings`, `name`, `allowDuplicateName`, `maxFileSize`, `workerTimeout`, `walCheckpointThreshold`, `workers`, `embeddingThreads`, `embeddingBatchSize`, `embeddingSubBatchSize`, `embeddingDevice`. +- Supported keys: `defaultBranch` (`branch`), `skipAgentsMd` (`skipContextFiles`, `skipAiContext`), `skipSkills`, `indexOnly`, `stats`/`noStats`, `embeddings`, `dropEmbeddings`, `name`, `allowDuplicateName`, `maxFileSize`, `workerTimeout`, `walCheckpointThreshold`, `workers`, `springActuator`, `embeddingThreads`, `embeddingBatchSize`, `embeddingSubBatchSize`, `embeddingDevice`. - The file is JSON only. Unknown keys and invalid values fail fast with an actionable error before analysis starts.
@@ -501,36 +568,39 @@ Notes: Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max-file-size`, `--verbose`). Use the env-var form when you'd otherwise repeat the same flag every run, or when invoking GitNexus from a long-running host (MCP server, eval-server, CI shell) that already manages its own environment. CLI flags take precedence over env vars; env vars take precedence over built-in defaults. -| Variable | Default | Effect | Tune when… | -| ----------------------------------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `GITNEXUS_WORKER_POOL_SIZE` | `cores - 1`, capped at 16 | Parse worker pool size (must be ≥ 1). Equivalent to `--workers `. The worker pool is the sole parse path — there is no sequential parser, so `0` is rejected with an actionable error (the pool self-heals via quarantine + respawn). | Constrained containers (cgroup CPU limits) or CI runners with explicit quotas. To narrow down a worker crash set `1` for a single-worker pool — not `0`. | -| `GITNEXUS_PARSE_CHUNK_CONCURRENCY` | `2` | Number of chunks whose file contents may be read into memory in parallel while the pool dispatches the current chunk. Worker dispatch itself stays serial. | Repos large enough to chunk (multi-MB total source) where disk I/O is a measurable fraction of analyze wall-clock. | -| `GITNEXUS_VERBOSE` | unset | When `1`, enables verbose ingestion logs (skipped-file warnings, per-chunk throughput, parse-cache stats). Equivalent to `--verbose`. | Debugging an analyze that "completed" but seems to have missed files; tuning `--workers` / chunk concurrency against observable throughput. | -| `GITNEXUS_AUTH_TOKEN` | unset | Bearer token required when `eval-server` binds beyond loopback. May also be read from `.env.local` or `.env`; shell values take precedence. | Exposing the evaluation HTTP tools to a container, VM, or LAN. | -| `GITNEXUS_PROFILE_DEFERRED` | unset | When `1`, emits `[deferred-profile]` timing/progress logs for the post-chunk deferred resolution band (imports → heritage → buildHeritageMap → legacy call resolution). Implied by `GITNEXUS_VERBOSE`. | Diagnosing analyze stalls in "Resolving calls (all chunks)" on large Java/Kotlin repos (issue #1741) without the full verbose ingestion noise. | -| `GITNEXUS_PROFILE_DEFERRED_SLOW_MS` | `3000` (verbose) / `5000` | Per-file threshold in ms above which `processCallsFromExtracted` emits a `slow file …` log line. Parsed via `Number()`: accepts integers (`5000`), scientific notation (`2.5e3`), decimals (`.5`), and hex (`0x10`). Non-finite or non-positive values fall back to the default. | Hunting a few outlier files dominating the deferred call-resolution stage; lower to surface more, raise to focus only on the worst. | -| `PROF_LBUG_LOAD` | unset | When `1`, emits one `[lbug-load prof]` summary line per `loadGraphToLbug` call breaking the graph-DB persistence wall into stages (`csv-emit` / `copy-nodes` / `copy-rels` / `fallback` / `total`) plus node & edge counts. Zero-cost when unset. | Attributing large-repo analyze wall time across CSV generation vs. LadybugDB `COPY` (issue #2203) — the analyze "emit" timing is the scope-resolution bucket, not this DB-write path. | -| `GITNEXUS_MAX_FILE_SIZE` | `512` (KB) | Walker skip threshold in KB. Hard cap is `32768` (tree-sitter buffer ceiling). Equivalent to `--max-file-size `. | Indexing repos with intentionally-large source files (generated parsers, vendored bundles) that should still be parsed. | -| `GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS` | `30000` | Worker idle timeout in milliseconds before retry/fallback. Equivalent to `--worker-timeout ` × 1000. | Slow-parsing files (large minified JS, deeply-nested TS types) that legitimately need more than 30s. | -| `GITNEXUS_WORKER_READY_TIMEOUT_MS` | `5000` | Startup budget in milliseconds for a parse worker to load its grammar bindings and report `{type:'ready'}`. Slots that miss it are treated as startup crashes. | Slow or heavily loaded hosts where a full pool cold-starting concurrently needs more than 5s, and analyze aborts with "did not report ready within 5000ms". | -| `GITNEXUS_FTS_STEMMER` | `porter` | Stemmer used when rebuilding BM25/FTS indexes. Use `none` for CJK-heavy repositories, or a language stemmer such as `german`, `french`, or `spanish` for matching repository comments. Re-run `gitnexus analyze --repair-fts` after changing it. | Keyword search quality is poor for non-English comments or identifiers under English stemming. | -| `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` | `67108864` (64 MiB) | LadybugDB WAL auto-checkpoint threshold in bytes. Equivalent to `--wal-checkpoint-threshold `. `-1` keeps LadybugDB's stock threshold (~16 MiB). Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. | You need a larger or smaller WAL auto-checkpoint threshold for your analyze workload. | -| `GITNEXUS_LBUG_BUFFER_POOL_SIZE` | min(2 GiB, 80% RAM) | LadybugDB buffer-pool ceiling in bytes for every GitNexus database (analyze, MCP server, serve, group bridges). `0` restores LadybugDB's native unbounded default of 80% of system RAM; invalid values warn and fall back to the default (#2557). During `analyze` the pool is right-sized to the graph, scaled on non-4 KiB-page hosts by the page-size granule ratio up to min(2 GiB × pageSize/4 KiB, 80% RAM) (#2631); this env var overrides all of that as an absolute value. | A long-lived `gitnexus mcp` or a big incremental `analyze` uses too much memory, or a huge repo's working set genuinely needs a pool larger than 2 GiB. | -| `GITNEXUS_LBUG_MAX_DB_SIZE` | `17179869184` (16 GiB) | Maximum size in bytes of a single LadybugDB database file — an mmap/disk-address-space ceiling, not a memory limit (it does not constrain the buffer pool). Invalid values silently fall back to the default. | Indexing a genuinely huge monorepo whose on-disk graph index approaches 16 GiB. | -| `GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES` | `8388608` (8 MB) | Per-job byte budget the pool will send to a worker in one `postMessage`. | Very large individual files; mostly diagnostic — bumping past 8 MB risks structured-clone memory pressure. | -| `GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT` | `3` | Max replacement spawns per worker slot before the slot is dropped from the active rotation. Bounds respawn loops on a chronically-crashing slot. | Hosts where a flaky worker should retry more (raise) or fail-fast (lower) before the slot is dropped. | -| `GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS` | `5 × subBatchTimeoutMs` | Total retry wall-time budget per job before quarantining. Combined with `timeoutBackoffFactor`, prevents exponentially-growing retries from stalling for hours. | Slow files that legitimately need long total retry windows; lower to fail-fast on stalls. | -| `GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD` | `max(3, poolSize)` | Per-slot consecutive deaths before the pool's circuit breaker trips. After tripping, every subsequent dispatch rejects until a fresh pool is created. | Hosts where a SIGSEGV-prone native grammar should trip the breaker sooner; CI runners that should fail loudly. | -| `GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS` | `30000` | Max wait at pool shutdown for a retired worker still inside native code. The worker is terminated at its next JS-safe point instead of mid-native-call (which aborts the whole process with `Napi::Error`, #2432); on expiry it is left running, unref'd, and terminated when it surfaces. | Shutdown latency matters more than draining a wedged worker (lower), or a legitimately-slow native grammar needs longer to surface (raise). | -| `GITNEXUS_CPP_CAPTURE_BUDGET_MS` | `20000` | Per-file wall-clock budget for C++ capture extraction. On breach the file keeps the captures accumulated so far and logs a warning — the worker returns to JS instead of stalling in native-heavy loops (#2432). `0` expires immediately. | Pathological generated C++ that still exceeds the budget after the indexed lookups; raise for completeness, lower to fail-fast. | -| `GITNEXUS_CHUNK_BYTE_BUDGET` | `2097152` (2 MB) | Chunk boundary used for cache-key composition and dispatch. Smaller = finer-grained cache hits but more dispatch overhead. | Tuning incremental-analyze cache behavior on monorepos. | -| `GITNEXUS_NO_GITIGNORE` | unset | When set, skips `.gitignore` parsing. `.gitnexusignore` is still honored. | Indexing a repo whose `.gitignore` excludes files you actually want indexed (e.g., generated code committed for cross-repo lookup). | -| `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` | unset | When `=1` strictly, skips the vendored grammar materialize for `tree-sitter-dart`, `tree-sitter-proto`, `tree-sitter-swift`, and `tree-sitter-kotlin` at install time (and the Dart/Proto source builds). Those four won't be parsed; the install still succeeds. | Installing on a host without a C++ toolchain or where the vendored prebuilds don't match; willing to skip Dart/Proto/Swift/Kotlin parsing. | -| `GITNEXUS_MCP_READ_ONLY` | unset | Set to `1` to expose only proven single-repository read tools and resources; `0` disables the policy and any other value fails startup. | The MCP server runs in an environment where graph mutation, raw Cypher, and cross-repository group routing must be unavailable. | -| `GITNEXUS_MCP_ALLOWED_REPOS` | unset | Comma-separated allowlist of canonical indexed repository names or absolute paths. Invalid, ambiguous, or blank entries fail startup. | One MCP process must expose only a bounded subset of the repositories in the global registry. | -| `GITNEXUS_MCP_DEFAULT_REPO` | unset | Canonical indexed repository name or absolute path used when a tool or resource omits its repository. Must belong to the allowlist when one is set. | Several repositories are available but unqualified MCP calls should resolve deterministically. | -| `GITNEXUS_MCP_DEFAULT_MAX_TOKENS` | unset | Default positive-integer response budget for MCP `query`, `context`, and `impact`, estimated at four UTF-8 bytes per token. Explicit `maxTokens` wins. | Long MCP responses consume too much model context and callers cannot reliably add a per-request budget. | -| `GITNEXUS_PUBLIC_ORIGIN` | unset | The single browser origin `serve` is reached through, added to the CORS allowlist and to the write-route origin guard. A wildcard bind (`0.0.0.0`) has no host identity, so without this the server's own UI is refused. **Setting it currently refuses to start:** `serve` has no authentication, requests carrying no `Origin` header already reach `POST /api/analyze` and `DELETE /api/repo`, and this is the setting that would admit browser writes on top of that. Matching rules for when the gate lifts: the hostname must match exactly, and so must the scheme. A value with no scheme (`app.example.com`) means `https`, since a bare host comes from platform service discovery and those terminate TLS; spell out `http://app.example.com` for plain HTTP. An explicit port must match; with no port, any port on that hostname is accepted. Anything that is not one reachable host (a list, `*`, a bare port number, a `:0` port, a trailing dot) warns at startup and allows nothing. | `gitnexus serve` runs behind a reverse proxy or on a wildcard bind, and the UI's index/delete requests return `origin_not_allowed`. | +| Variable | Default | Effect | Tune when… | +| ----------------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GITNEXUS_WORKER_POOL_SIZE` | `cores - 1`, capped at 16 | Parse worker pool size (must be ≥ 1). Equivalent to `--workers `. The worker pool is the sole parse path — there is no sequential parser, so `0` is rejected with an actionable error (the pool self-heals via quarantine + respawn). | Constrained containers (cgroup CPU limits) or CI runners with explicit quotas. To narrow down a worker crash set `1` for a single-worker pool — not `0`. | +| `GITNEXUS_PARSE_CHUNK_CONCURRENCY` | `2` | Number of chunks whose file contents may be read into memory in parallel while the pool dispatches the current chunk. Worker dispatch itself stays serial. | Repos large enough to chunk (multi-MB total source) where disk I/O is a measurable fraction of analyze wall-clock. | +| `GITNEXUS_VERBOSE` | unset | When `1`, enables verbose ingestion logs (skipped-file warnings, per-chunk throughput, parse-cache stats). Equivalent to `--verbose`. | Debugging an analyze that "completed" but seems to have missed files; tuning `--workers` / chunk concurrency against observable throughput. | +| `GITNEXUS_ANALYZER_IDENTITY_IN_PROCESS_GUARDS` | unset | When truthy (`1`/`true`/`yes`), forces in-process cache-guard validation once a batch has ≥128 requests. In-process mode also auto-selects when `packageRoot`/`buildRoot` fail `W_OK` with `EACCES`/`EROFS`. Otherwise those large batches use a Node subprocess probe. Batches under 128 always stay in-process. | Trusted or read-only installs where two identity subprocess spawns per analyze dominate wall time; leave unset to keep the default isolation path on writable trees. | +| `GITNEXUS_RESOLVE_DEF_GRAPH_ID_MEMO` | on (unset) | Memoizes `resolveDefGraphId` per `nodeLookup` instance (WeakMap). Enabled by default. Set to `0`/`false`/`off`/`no` to disable and recompute on every call (debug / bisect memo bugs). | Suspecting stale graph-id resolution after a lookup rebuild, or comparing memo vs uncached cost on a large index. | +| `GITNEXUS_AUTH_TOKEN` | unset | Bearer token required when `eval-server` binds beyond loopback. May also be read from `.env.local` or `.env`; shell values take precedence. | Exposing the evaluation HTTP tools to a container, VM, or LAN. | +| `GITNEXUS_MCP_AUTH_TOKEN` | unset | Bearer token for the dedicated `gitnexus mcp --http` server, for a **directly reachable** `gitnexus serve` `/api/mcp` route, and for the `docker-server` / web proxy in front of one. A non-loopback dedicated MCP bind requires it; `serve` enables protocol-layer MCP auth when it is set. Behind a proxy, set the **same** value on both services: the proxy spends the edge `GITNEXUS_SERVE_AUTH_TOKEN`, then replaces `Authorization` with this token on `/api/mcp` only. | Dedicated MCP, a `serve` the client can reach directly, or a proxied deploy (Render Blueprint) where the backend runs protocol-layer MCP auth — configure it on the proxy too. | +| `GITNEXUS_PROFILE_DEFERRED` | unset | When `1`, emits `[deferred-profile]` timing/progress logs for the post-chunk deferred resolution band (imports → heritage → buildHeritageMap → legacy call resolution). Implied by `GITNEXUS_VERBOSE`. | Diagnosing analyze stalls in "Resolving calls (all chunks)" on large Java/Kotlin repos (issue #1741) without the full verbose ingestion noise. | +| `GITNEXUS_PROFILE_DEFERRED_SLOW_MS` | `3000` (verbose) / `5000` | Per-file threshold in ms above which `processCallsFromExtracted` emits a `slow file …` log line. Parsed via `Number()`: accepts integers (`5000`), scientific notation (`2.5e3`), decimals (`.5`), and hex (`0x10`). Non-finite or non-positive values fall back to the default. | Hunting a few outlier files dominating the deferred call-resolution stage; lower to surface more, raise to focus only on the worst. | +| `PROF_LBUG_LOAD` | unset | When `1`, emits one `[lbug-load prof]` summary line per `loadGraphToLbug` call breaking the graph-DB persistence wall into stages (`csv-emit` / `copy-nodes` / `copy-rels` / `fallback` / `total`) plus node & edge counts. Zero-cost when unset. | Attributing large-repo analyze wall time across CSV generation vs. LadybugDB `COPY` (issue #2203) — the analyze "emit" timing is the scope-resolution bucket, not this DB-write path. | +| `GITNEXUS_MAX_FILE_SIZE` | `512` (KB) | Walker skip threshold in KB. Hard cap is `32768` (tree-sitter buffer ceiling). Equivalent to `--max-file-size `. | Indexing repos with intentionally-large source files (generated parsers, vendored bundles) that should still be parsed. | +| `GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS` | `30000` | Worker idle timeout in milliseconds before retry/fallback. Equivalent to `--worker-timeout ` × 1000. | Slow-parsing files (large minified JS, deeply-nested TS types) that legitimately need more than 30s. | +| `GITNEXUS_WORKER_READY_TIMEOUT_MS` | `5000` | Startup budget in milliseconds for a parse worker to load its grammar bindings and report `{type:'ready'}`. Slots that miss it are treated as startup crashes. | Slow or heavily loaded hosts where a full pool cold-starting concurrently needs more than 5s, and analyze aborts with "did not report ready within 5000ms". | +| `GITNEXUS_FTS_STEMMER` | `porter` | Stemmer used when rebuilding BM25/FTS indexes. Use `none` for CJK-heavy repositories, or a language stemmer such as `german`, `french`, or `spanish` for matching repository comments. Re-run `gitnexus analyze --repair-fts` after changing it. | Keyword search quality is poor for non-English comments or identifiers under English stemming. | +| `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` | `67108864` (64 MiB) | LadybugDB WAL auto-checkpoint threshold in bytes. Equivalent to `--wal-checkpoint-threshold `. `-1` keeps LadybugDB's stock threshold (~16 MiB). Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. | You need a larger or smaller WAL auto-checkpoint threshold for your analyze workload. | +| `GITNEXUS_LBUG_BUFFER_POOL_SIZE` | min(2 GiB, 80% RAM) | LadybugDB buffer-pool ceiling in bytes for every GitNexus database (analyze, MCP server, serve, group bridges). `0` restores LadybugDB's native unbounded default of 80% of system RAM; invalid values warn and fall back to the default (#2557). During `analyze` the pool is right-sized to the graph, scaled on non-4 KiB-page hosts by the page-size granule ratio up to min(2 GiB × pageSize/4 KiB, 80% RAM) (#2631); this env var overrides all of that as an absolute value. | A long-lived `gitnexus mcp` or a big incremental `analyze` uses too much memory, or a huge repo's working set genuinely needs a pool larger than 2 GiB. | +| `GITNEXUS_LBUG_MAX_DB_SIZE` | `17179869184` (16 GiB) | Maximum size in bytes of a single LadybugDB database file — an mmap/disk-address-space ceiling, not a memory limit (it does not constrain the buffer pool). Invalid values silently fall back to the default. | Indexing a genuinely huge monorepo whose on-disk graph index approaches 16 GiB. | +| `GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES` | `8388608` (8 MB) | Per-job byte budget the pool will send to a worker in one `postMessage`. | Very large individual files; mostly diagnostic — bumping past 8 MB risks structured-clone memory pressure. | +| `GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT` | `3` | Max replacement spawns per worker slot before the slot is dropped from the active rotation. Bounds respawn loops on a chronically-crashing slot. | Hosts where a flaky worker should retry more (raise) or fail-fast (lower) before the slot is dropped. | +| `GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS` | `5 × subBatchTimeoutMs` | Total retry wall-time budget per job before quarantining. Combined with `timeoutBackoffFactor`, prevents exponentially-growing retries from stalling for hours. | Slow files that legitimately need long total retry windows; lower to fail-fast on stalls. | +| `GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD` | `max(3, poolSize)` | Per-slot consecutive deaths before the pool's circuit breaker trips. After tripping, every subsequent dispatch rejects until a fresh pool is created. | Hosts where a SIGSEGV-prone native grammar should trip the breaker sooner; CI runners that should fail loudly. | +| `GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS` | `30000` | Max wait at pool shutdown for a retired worker still inside native code. The worker is terminated at its next JS-safe point instead of mid-native-call (which aborts the whole process with `Napi::Error`, #2432); on expiry it is left running, unref'd, and terminated when it surfaces. | Shutdown latency matters more than draining a wedged worker (lower), or a legitimately-slow native grammar needs longer to surface (raise). | +| `GITNEXUS_CPP_CAPTURE_BUDGET_MS` | `20000` | Per-file wall-clock budget for C++ capture extraction. On breach the file keeps the captures accumulated so far and logs a warning — the worker returns to JS instead of stalling in native-heavy loops (#2432). `0` expires immediately. | Pathological generated C++ that still exceeds the budget after the indexed lookups; raise for completeness, lower to fail-fast. | +| `GITNEXUS_CHUNK_BYTE_BUDGET` | `2097152` (2 MB) | Per-bucket byte budget for parse-cache packing. Files are grouped by `(language, hash(path) mod 128)`; packs inside a bucket are cut at this limit. Smaller = finer-grained invalidation and more dispatch. Default is always 2 MiB and no longer scales with worker count. | Tuning incremental-analyze cache invalidation on monorepos without changing `--workers`. | +| `GITNEXUS_NO_GITIGNORE` | unset | When set, skips `.gitignore` parsing. `.gitnexusignore` is still honored. | Indexing a repo whose `.gitignore` excludes files you actually want indexed (e.g., generated code committed for cross-repo lookup). | +| `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` | unset | When `=1` strictly, skips the vendored grammar materialize for `tree-sitter-dart`, `tree-sitter-proto`, `tree-sitter-swift`, and `tree-sitter-kotlin` at install time (and the Dart/Proto source builds). Those four won't be parsed; the install still succeeds. | Installing on a host without a C++ toolchain or where the vendored prebuilds don't match; willing to skip Dart/Proto/Swift/Kotlin parsing. | +| `GITNEXUS_MCP_READ_ONLY` | unset | Set to `1` to expose only proven single-repository read tools and resources; `0` disables the policy and any other value fails startup. | The MCP server runs in an environment where graph mutation, raw Cypher, and cross-repository group routing must be unavailable. | +| `GITNEXUS_MCP_ALLOWED_REPOS` | unset | Comma-separated allowlist of canonical indexed repository names or absolute paths. Invalid, ambiguous, or blank entries fail startup. | One MCP process must expose only a bounded subset of the repositories in the global registry. | +| `GITNEXUS_MCP_DEFAULT_REPO` | unset | Canonical indexed repository name or absolute path used when a tool or resource omits its repository. Must belong to the allowlist when one is set. | Several repositories are available but unqualified MCP calls should resolve deterministically. | +| `GITNEXUS_MCP_DEFAULT_MAX_TOKENS` | unset | Default positive-integer response budget for MCP `query`, `context`, and `impact`, estimated at four UTF-8 bytes per token. Explicit `maxTokens` wins. | Long MCP responses consume too much model context and callers cannot reliably add a per-request budget. | +| `GITNEXUS_PUBLIC_ORIGIN` | unset | The single browser origin `serve` is reached through, added to the CORS allowlist and to the write-route origin guard. A wildcard bind (`0.0.0.0`) has no host identity, so without this the server's own UI is refused. **Setting it currently refuses to start:** `serve` has no authentication, requests carrying no `Origin` header already reach `POST /api/analyze` and `DELETE /api/repo`, and this is the setting that would admit browser writes on top of that. Matching rules for when the gate lifts: the hostname must match exactly, and so must the scheme. A value with no scheme (`app.example.com`) means `https`, since a bare host comes from platform service discovery and those terminate TLS; spell out `http://app.example.com` for plain HTTP. An explicit port must match; with no port, any port on that hostname is accepted. Anything that is not one reachable host (a list, `*`, a bare port number, a `:0` port, a trailing dot) warns at startup and allows nothing. | `gitnexus serve` runs behind a reverse proxy or on a wildcard bind, and the UI's index/delete requests return `origin_not_allowed`. | | `GITNEXUS_TRUST_PROXY` | `loopback, linklocal, uniquelocal` | Express `trust proxy` value — which upstream hops may set `X-Forwarded-*`, and so what the per-IP rate limiter reads as the client IP. Set it to the exact number of proxies you control. Every hop past that is one more entry of the chain the caller gets to write. `false`/`no`/`off` (and a `0` hop count) trust no hop; a proxy list Express can compile (`loopback`, `10.0.0.0/8, 127.0.0.1`) names them instead. `true`/`yes`/`on` is **rejected**: it reads the client-controlled leftmost `X-Forwarded-For` entry, so a spoofed chain earns a fresh rate-limit key per request, and express-rate-limit rejects it too (`ERR_ERL_PERMISSIVE_TRUST_PROXY`). Counts above `16` are rejected as well, as a sanity ceiling rather than a safety boundary. Any invalid value warns and falls back to the default. Bind non-loopback with this unset and `serve` warns: a load balancer outside the private ranges is untrusted, so every request keys to the balancer and the per-IP limit becomes one shared limit. | `serve` sits behind a load balancer outside the private ranges (AWS ALB, Cloudflare, CGNAT), where every request otherwise collapses to the proxy hop and rate limiting goes global. | @@ -589,7 +659,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.
Architecture diagram @@ -767,6 +837,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 diff --git a/RUNBOOK.md b/RUNBOOK.md index 0f5c8b7bb..d16ccd52d 100644 --- a/RUNBOOK.md +++ b/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 diff --git a/SECURITY.md b/SECURITY.md index d1fbcd051..37d216911 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -59,11 +59,16 @@ The `render.yaml` Blueprint (see the README's **Deploy to Render**) puts `gitnex - **The generated `GITNEXUS_SERVE_AUTH_TOKEN` is the only access control.** The proxy rejects any `/api/*` request without it with a `401` before forwarding. Rotate it by editing the environment variable on the `gitnexus-web` service and redeploying. - **The CSRF guard is inert on this path.** The proxy strips `Origin` before forwarding, so the server's write-origin guard does nothing for proxied traffic — it passes `Origin`-less requests through by design. The token is not a second layer behind the guard. - **Anyone holding the token can read every indexed repo's source.** These routes carry no origin guard, and the first three carry no rate limiter either: `GET /api/repos`, `GET /api/graph`, `POST /api/query`, `GET /api/file`, `GET /api/grep`. Whoever has the token can also index and delete repositories. -- **`POST /api/mcp` rides the same path.** `serve` mounts the MCP handler via `mountMCPEndpoints`, and `createStreamableHttpHandler` is called with no `authToken` — a **pre-existing** gap in `serve` itself, not something this deploy introduces. On Render it is closed only by the edge token and the private network. A `serve` bound directly to a public interface has no such cover. +- **`POST /api/mcp` rides the same path.** When `GITNEXUS_MCP_AUTH_TOKEN` is set on the backend, `serve` protects `/api/mcp` with the same constant-time Bearer check as the dedicated HTTP MCP server, before parsing the request body. The Render Blueprint does not set a backend MCP token by default. To enable it behind the proxy, set the **same** `GITNEXUS_MCP_AUTH_TOKEN` on both the `gitnexus-web` proxy and the `gitnexus-server` backend: the proxy consumes the edge `GITNEXUS_SERVE_AUTH_TOKEN`, then replaces `Authorization` with the MCP token on `/api/mcp` (and its subpaths) only — the edge credential is never forwarded, and other `/api/*` routes stay stripped. Configuring it on the backend alone makes every proxied MCP request `401`. +- **A directly reachable `serve` still needs an explicit control.** If neither `GITNEXUS_MCP_AUTH_TOKEN` nor an authenticated edge/private-network boundary is present, `/api/mcp` is unauthenticated. Do not bind that topology to a LAN or public interface: MCP readers can access indexed source and graph context. - **Rate limits bound cost, not access.** They cap what a token holder can spend; they do not decide who gets in. Do not hand the URL out as a public demo. A token holder has read access to everything the deploy has indexed. +### `/api/grep` regex semantics and residual ReDoS exposure + +`GET /api/grep` executes caller-supplied patterns as real regular expressions (with an optional path-substring `fileFilter` and `caseSensitive` flag) to honor the web chat's grep tool contract; `literal=1` restores the older escaped-substring mode. Mitigations: a 200-character pattern cap, line-by-line matching, a max-200 result cap, and a 5-second wall-clock budget. Matching runs in a `worker_threads` worker so a catastrophic pattern (e.g. `(a+)+$`) can be killed with `terminate()` when the budget expires — the parent event loop (other routes + SSE) stays responsive. A timed-out scan returns partial results with `timedOut: true`; the web grep tool surfaces that flag so an agent does not treat a cut-off scan as exhaustive. CodeQL still flags constructing a `RegExp` from the query string; that is the advertised contract, not accidental injection. Hosted deploys continue to gate the route behind the edge token. + ## Automated Scans Running in CI This repository runs the following scans automatically. Findings appear under the repository's **Security → Code scanning** tab. diff --git a/docker-server.mjs b/docker-server.mjs index e3e9f68ee..3b036f7db 100644 --- a/docker-server.mjs +++ b/docker-server.mjs @@ -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); diff --git a/docker-server.test.mjs b/docker-server.test.mjs index 80e742f7e..6d2a9f6c2 100644 --- a/docker-server.test.mjs +++ b/docker-server.test.mjs @@ -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) => { diff --git a/docs/plans/2026-08-28-gitnexus-plan-impact-file-risk.md b/docs/plans/2026-08-28-gitnexus-plan-impact-file-risk.md new file mode 100644 index 000000000..7e14f2174 --- /dev/null +++ b/docs/plans/2026-08-28-gitnexus-plan-impact-file-risk.md @@ -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. diff --git a/eslint-rules/require-safe-parse.mjs b/eslint-rules/require-safe-parse.mjs index 4ab9dbd8a..4bad4280d 100644 --- a/eslint-rules/require-safe-parse.mjs +++ b/eslint-rules/require-safe-parse.mjs @@ -19,14 +19,14 @@ * * False-positive suppression: * - Skips calls whose receiver is a known non-tree-sitter library (`JSON`, - * `URL`, `marked`, `Number`). + * `URL`, `marked`, `Number`, `path`). * - Skips calls whose first argument is a string-literal (grammar-load smoke * tests like `_testParser.parse('service X { rpc Y (R) returns (R); }')`). * - Skips test files (`.test.ts`/`.test.tsx`/`.spec.ts`). * - Skips the `safe-parse.ts` helper itself. */ -const SKIPPED_RECEIVERS = new Set(['JSON', 'URL', 'marked', 'Number', 'Math']); +const SKIPPED_RECEIVERS = new Set(['JSON', 'URL', 'marked', 'Number', 'Math', 'path']); export default { meta: { @@ -74,7 +74,7 @@ export default { // Receiver-text-shape skip: anything matching well-known JS APIs that // happen to have a `.parse()` shape but aren't tree-sitter. if ( - /^(JSON|URL|marked|Number|Math|Date|globalThis\.JSON)\b/.test(receiverText) || + /^(JSON|URL|marked|Number|Math|Date|path|globalThis\.JSON)\b/.test(receiverText) || /\bjson\.parse\b/i.test(receiverText) ) { return; diff --git a/eval/workflow_bench/runtime_mounts.py b/eval/workflow_bench/runtime_mounts.py index 04bacdf55..6e85dc272 100644 --- a/eval/workflow_bench/runtime_mounts.py +++ b/eval/workflow_bench/runtime_mounts.py @@ -29,6 +29,9 @@ from .proposer_sandbox import ( ) 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" diff --git a/gitnexus-claude-plugin/.claude-plugin/plugin.json b/gitnexus-claude-plugin/.claude-plugin/plugin.json index ace058dad..9e9372dea 100644 --- a/gitnexus-claude-plugin/.claude-plugin/plugin.json +++ b/gitnexus-claude-plugin/.claude-plugin/plugin.json @@ -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" }, diff --git a/gitnexus-claude-plugin/.codex-plugin/plugin.json b/gitnexus-claude-plugin/.codex-plugin/plugin.json index c9a03db4d..67ef62af2 100644 --- a/gitnexus-claude-plugin/.codex-plugin/plugin.json +++ b/gitnexus-claude-plugin/.codex-plugin/plugin.json @@ -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", diff --git a/gitnexus-claude-plugin/hooks/gitnexus-hook.js b/gitnexus-claude-plugin/hooks/gitnexus-hook.js index a53d79f29..238438455 100644 --- a/gitnexus-claude-plugin/hooks/gitnexus-hook.js +++ b/gitnexus-claude-plugin/hooks/gitnexus-hook.js @@ -543,7 +543,7 @@ function handlePostToolUse(input) { // If HEAD matches last indexed commit, no reindex needed if (currentHead && currentHead === lastCommit) return; - const analyzeCmd = formatAnalyzeCommand({ embeddings: hadEmbeddings }); + const analyzeCmd = formatAnalyzeCommand({ embeddings: hadEmbeddings, indexOnly: true }); sendHookResponse( 'PostToolUse', `GitNexus index is stale (last indexed: ${lastCommit ? lastCommit.slice(0, 7) : 'never'}). ` + diff --git a/gitnexus-claude-plugin/hooks/resolve-analyze-cmd.cjs b/gitnexus-claude-plugin/hooks/resolve-analyze-cmd.cjs index 56f5235fb..c74f03f5d 100644 --- a/gitnexus-claude-plugin/hooks/resolve-analyze-cmd.cjs +++ b/gitnexus-claude-plugin/hooks/resolve-analyze-cmd.cjs @@ -276,7 +276,13 @@ function formatBunxCommand(gitnexusArgs) { } function formatAnalyzeCommand(options = {}, deps = {}) { - const suffix = options.embeddings ? ' --embeddings' : ''; + // `--index-only` is what a routine "your index is stale" nudge wants: it + // reindexes without rewriting AGENTS.md / CLAUDE.md / skills, so an agent + // following the nudge on every commit cannot churn the tracked agent guides + // (#2907). Callers that actually want the docs refreshed omit it. + const suffix = `${options.indexOnly ? ' --index-only' : ''}${ + options.embeddings ? ' --embeddings' : '' + }`; // Keep the stale-index hook budget tight by querying each tool at most once. // The memoized `probe` is a spawn-free PATH scan (resolveOnPath) shared with // resolveInvocationMode, so `gitnexus` is scanned only once and no subprocess diff --git a/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md b/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md index 91c1ae992..be02d92fd 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md +++ b/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md @@ -19,14 +19,21 @@ node .gitnexus/run.cjs analyze Run from the project root. This parses all source files, builds the knowledge graph, writes it to `.gitnexus/`, and generates CLAUDE.md / AGENTS.md context files. -| Flag | Effect | -|------|--------| -| `--force` | Force full re-index even if up to date | +| Flag | Effect | +| -------------- | ---------------------------------------------------------------- | +| `--watch` | Keep a Git repository index current with serialized refreshes | +| `--debounce ` | Watch quiet period before refresh (default: 300 ms) | +| `--force` | Force full re-index even if up to date | | `--embeddings` | Enable embedding generation for semantic search (off by default) | | `--drop-embeddings` | Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` preserves them. | | `--pdg` | Build the program-dependence layers used by `explain` and `pdg_query` (taint, CDG, and REACHING_DEF). | +| `--spring-actuator ` | Import opt-in Spring Boot Actuator mappings, beans, conditions, configprops, and env snapshots. Forces a full rebuild; unsupported with `--watch`. | -**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. +**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook detects staleness after `git commit` and `git merge` and notifies the agent to run `analyze` — the hook does not run analyze itself, to avoid blocking the agent for up to 120s and risking KuzuDB corruption on timeout. + +For Spring runtime enrichment, pass a JSON bundle, one endpoint JSON file, or a directory containing endpoint files. Route evidence is authoritative only when `runtimeConfirmed === true`; `runtimeSource` records provenance and may also accompany `handler-conflict`. Env/configprops values are never persisted. + +Use `node .gitnexus/run.cjs analyze --watch` for a long-lived local Git repository. It performs an initial analysis, queues scanner-admitted file changes, and retries intact failed batches with bounded backoff. Watch refreshes update only the graph: they skip AGENTS.md / CLAUDE.md injection and standard skill installation, so run a one-shot `analyze` when those generated files need updating. Watch rejects one-shot or context-output flags including `--force`, embedding flags, `--skills`, `--default-branch`, `--skip-agents-md`, `--skip-skills`, `--no-stats`, `--self-commit`, `--index-only`, and `--skip-git`. It never pulls remotes. Scheduled remote clone/pull is a different command: `gitnexus auto-sync`. Bare `gitnexus watch` is reserved and does not start either job. Running MCP and `serve` processes periodically check for a published replacement and reopen it without a restart. MCP checks are throttled to once every five seconds, so a tool call before the next check can briefly use the previous index. ### status — Check index freshness @@ -44,10 +51,10 @@ node .gitnexus/run.cjs clean Deletes the `.gitnexus/` directory and unregisters the repo from the global registry. Use before re-indexing if the index is corrupt or after removing GitNexus from a project. -| Flag | Effect | -|------|--------| -| `--force` | Skip confirmation prompt | -| `--all` | Clean all indexed repos, not just the current one | +| Flag | Effect | +| --------- | ------------------------------------------------- | +| `--force` | Skip confirmation prompt | +| `--all` | Clean all indexed repos, not just the current one | ### wiki — Generate documentation from the graph @@ -55,19 +62,21 @@ Deletes the `.gitnexus/` directory and unregisters the repo from the global regi node .gitnexus/run.cjs wiki ``` -Generates repository documentation from the knowledge graph using an LLM. Requires an API key (saved to `~/.gitnexus/config.json` on first use). +Generates repository documentation from the knowledge graph using an LLM. HTTP providers require an API key (saved to `~/.gitnexus/config.json` on first use). Local CLI providers (`--provider cursor|claude|codex|opencode|grok`) use your existing CLI login. -| Flag | Effect | -|------|--------| -| `--force` | Force full regeneration, also required to re-gerenate an existing wiki in a different language | -| `--model ` | LLM model (default: minimax/minimax-m2.5) | -| `--base-url ` | LLM API base URL | -| `--api-key ` | LLM API key | -| `--concurrency ` | 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 ` | 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 ` | LLM model (default: MiniMax-M3) | +| `--base-url ` | LLM API base URL | +| `--api-key ` | LLM API key | +| `--concurrency ` | Parallel LLM calls (default: 3) | | `--timeout ` | LLM request timeout in seconds (default: disabled) | -| `--retries ` | Max LLM retry attempts per request (default: 3) | -| `--lang ` | Output language for generated documentation (e.g. english, chinese, spanish, japanese)| +| `--retries ` | Max LLM retry attempts per request (default: 3) | +| `--lang ` | Output language for generated documentation (e.g. english, chinese, spanish, japanese) | +| `--gist` | Publish wiki as a public GitHub Gist | + ### list — Show all indexed repos ```bash @@ -84,5 +93,5 @@ Lists all repositories registered in `~/.gitnexus/registry.json`. The MCP `list_ ## Troubleshooting - **"Not inside a git repository"**: Run from a directory inside a git repo -- **Index is stale after re-analyzing**: Restart Claude Code to reload the MCP server +- **Index is stale after re-analyzing**: Wait for the next MCP tool call to reopen the published index; this normally takes no more than five seconds - **Embeddings slow**: Omit `--embeddings` (it's off by default) or set `OPENAI_API_KEY` for faster API-based embedding diff --git a/gitnexus-claude-plugin/skills/gitnexus-cli/mcp.json b/gitnexus-claude-plugin/skills/gitnexus-cli/mcp.json index 4fe6590dd..1af6255fb 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-cli/mcp.json +++ b/gitnexus-claude-plugin/skills/gitnexus-cli/mcp.json @@ -2,7 +2,7 @@ "mcpServers": { "gitnexus": { "command": "npx", - "args": ["-y", "gitnexus@1.6.9", "mcp"] + "args": ["-y", "gitnexus@1.6.10", "mcp"] } } } diff --git a/gitnexus-claude-plugin/skills/gitnexus-debugging/SKILL.md b/gitnexus-claude-plugin/skills/gitnexus-debugging/SKILL.md index 4a33e589a..41fb568f8 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-debugging/SKILL.md +++ b/gitnexus-claude-plugin/skills/gitnexus-debugging/SKILL.md @@ -13,9 +13,28 @@ description: "Use when the user is debugging a bug, tracing an error, or asking - "This endpoint returns 500" - Investigating bugs, errors, or unexpected behavior +## Bind the repository first + +A root cause traced in the wrong repository is a wrong root cause. + +Call `list_repos {}` before the first tool call. With one indexed repository, +use the examples below as written. With more than one, pass `repo` on every +call: an omitted `repo` normally errors, but under an MCP policy with a +configured default it resolves to that default silently. If you cannot tell +which repository is meant, stop and ask. This matters most for `cypher`, whose +statement carries no in-band hint of which database it ran against. + +`list_repos` is paginated, so page with `offset: pagination.nextOffset` until +`hasMore` is false before concluding a repository is absent. + +A stale index describes the code from before your bug, so refresh before +trusting a trace, and state the repository and index freshness with the +diagnosis. + ## Workflow ``` +0. list_repos {} → Bind repo 1. query({search_query: ""}) → Find related execution flows 2. context({name: ""}) → See callers/callees/processes 3. READ gitnexus://repo/{name}/process/{name} → Trace execution flow @@ -27,6 +46,7 @@ description: "Use when the user is debugging a bug, tracing an error, or asking ## Checklist ``` +- [ ] list_repos {} — bind repo; explicit repo when >1 indexed, ask if ambiguous - [ ] Understand the symptom (error message, unexpected behavior) - [ ] query for error text or related code - [ ] Identify the suspect function from returned processes @@ -34,6 +54,7 @@ description: "Use when the user is debugging a bug, tracing an error, or asking - [ ] Trace execution flow via process resource if applicable - [ ] cypher for custom call chain traces if needed - [ ] Read source files to confirm root cause +- [ ] State the repository and index freshness with the diagnosis ``` ## Debugging Patterns @@ -44,7 +65,7 @@ description: "Use when the user is debugging a bug, tracing an error, or asking | Wrong return value | `context` on the function → trace callees for data flow | | Intermittent failure | `context` → look for external calls, async deps | | Performance issue | `context` → find symbols with many callers (hot paths) | -| Recent regression | `detect_changes` to see what your changes affect | +| Recent regression | `detect_changes` to see what your changes affect — pass `worktree` for a linked worktree | | "How does A reach B?" | `trace` between the two symbols — shortest call chain in one call | ## Tools @@ -52,7 +73,7 @@ description: "Use when the user is debugging a bug, tracing an error, or asking **query** — find code related to error: ``` -query({search_query: "payment validation error"}) +query({search_query: "payment validation error", repo: "my-app"}) → Processes: CheckoutFlow, ErrorHandling → Symbols: validatePayment, handlePaymentError, PaymentException ``` @@ -60,13 +81,15 @@ query({search_query: "payment validation error"}) **context** — full context for a suspect: ``` -context({name: "validatePayment"}) +context({name: "validatePayment", repo: "my-app"}) → Incoming calls: processCheckout, webhookHandler → Outgoing calls: verifyCard, fetchRates (external API!) → Processes: CheckoutFlow (step 3/7) ``` -**cypher** — custom call chain traces: +**cypher** — custom call chain traces. Pass `repo` alongside the statement; the +Cypher text itself names no repository, so the result is unattributable without +it: ```cypher MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Function {name: "validatePayment"}) @@ -76,7 +99,7 @@ RETURN [n IN nodes(path) | n.name] AS chain **trace** — shortest call chain between two symbols ("how does A reach B?"), one call instead of chaining `context` hops: ``` -trace({ from: "processCheckout", to: "fetchRates" }) +trace({ from: "processCheckout", to: "fetchRates", repo: "my-app" }) → status: ok, hopCount: 3 → hops: processCheckout → validatePayment → verifyCard → fetchRates → edges: CALLS (1.0), CALLS (0.95), CALLS (1.0) @@ -87,15 +110,22 @@ When no path exists, `trace` reports the furthest reachable node — exactly whe ## Example: "Payment endpoint returns 500 intermittently" ``` -1. query({search_query: "payment error handling"}) +0. list_repos {} + → total: 2 (my-app, billing-api) — bind my-app explicitly on every call + +1. query({search_query: "payment error handling", repo: "my-app"}) → Processes: CheckoutFlow, ErrorHandling → Symbols: validatePayment, handlePaymentError -2. context({name: "validatePayment"}) +2. context({name: "validatePayment", repo: "my-app"}) → Outgoing calls: verifyCard, fetchRates (external API!) 3. READ gitnexus://repo/my-app/process/CheckoutFlow → Step 3: validatePayment → calls fetchRates (external) 4. Root cause: fetchRates calls external API without proper timeout + Repository: my-app Index: current ``` + +With a single indexed repository, step 0 returns `total: 1` and the `repo` +argument drops out of every call above. diff --git a/gitnexus-claude-plugin/skills/gitnexus-debugging/mcp.json b/gitnexus-claude-plugin/skills/gitnexus-debugging/mcp.json index 4fe6590dd..1af6255fb 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-debugging/mcp.json +++ b/gitnexus-claude-plugin/skills/gitnexus-debugging/mcp.json @@ -2,7 +2,7 @@ "mcpServers": { "gitnexus": { "command": "npx", - "args": ["-y", "gitnexus@1.6.9", "mcp"] + "args": ["-y", "gitnexus@1.6.10", "mcp"] } } } diff --git a/gitnexus-claude-plugin/skills/gitnexus-exploring/SKILL.md b/gitnexus-claude-plugin/skills/gitnexus-exploring/SKILL.md index f483c2fd6..46fc187ce 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-exploring/SKILL.md +++ b/gitnexus-claude-plugin/skills/gitnexus-exploring/SKILL.md @@ -13,10 +13,22 @@ description: "Use when the user asks how code works, wants to understand archite - "Where is the database logic?" - Understanding code you haven't seen before +## Bind the repository first + +Step 1 discovers what is indexed; every call after it must say which of those +it means. With one indexed repository, use the examples below as written. With +more than one, pass `repo` on every call: an omitted `repo` normally errors, +but under an MCP policy with a configured default it resolves to that default +silently. If you cannot tell which repository is meant, stop and ask. Report +the bound repository and index freshness alongside your explanation. + +`list_repos` is paginated, so page with `offset: pagination.nextOffset` until +`hasMore` is false before concluding a repository is absent. + ## Workflow ``` -1. READ gitnexus://repos → Discover indexed repos +1. list_repos {} or READ gitnexus://repos → Discover indexed repos 2. READ gitnexus://repo/{name}/context → Codebase overview, check staleness 3. query({search_query: ""}) → Find related execution flows 4. context({name: ""}) → Deep dive on specific symbol @@ -28,12 +40,14 @@ description: "Use when the user asks how code works, wants to understand archite ## Checklist ``` +- [ ] list_repos {} — bind repo; explicit repo when >1 indexed, ask if ambiguous - [ ] READ gitnexus://repo/{name}/context - [ ] query for the concept you want to understand - [ ] Review returned processes (execution flows) - [ ] context on key symbols for callers/callees - [ ] READ process resource for full execution traces - [ ] Read source files for implementation details +- [ ] State the repository and index freshness with the explanation ``` ## Resources @@ -50,7 +64,7 @@ description: "Use when the user asks how code works, wants to understand archite **query** — find execution flows related to a concept: ``` -query({search_query: "payment processing"}) +query({search_query: "payment processing", repo: "my-app"}) → Processes: CheckoutFlow, RefundFlow, WebhookHandler → Symbols grouped by flow with file locations ``` @@ -58,16 +72,20 @@ query({search_query: "payment processing"}) **context** — 360-degree view of a symbol: ``` -context({name: "validateUser"}) +context({name: "validateUser", repo: "my-app"}) → Incoming calls: loginHandler, apiMiddleware → Outgoing calls: checkToken, getUserById → Processes: LoginFlow (step 2/5), TokenRefresh (step 1/3) ``` +`repo` is required once more than one repository is indexed, and may be omitted +with a single one. + ## Example: "How does payment processing work?" ``` -1. READ gitnexus://repo/my-app/context → 918 symbols, 45 processes +1. list_repos {} → total: 1 (my-app) — bind it + READ gitnexus://repo/my-app/context → 918 symbols, 45 processes 2. query({search_query: "payment processing"}) → CheckoutFlow: processPayment → validateCard → chargeStripe → RefundFlow: initiateRefund → calculateRefund → processRefund @@ -75,4 +93,8 @@ context({name: "validateUser"}) → Incoming: checkoutHandler, webhookHandler → Outgoing: validateCard, chargeStripe, saveTransaction 4. Read src/payments/processor.ts for implementation details +5. Answer, noting: Repository my-app, index current ``` + +Had step 1 returned two repositories, every call above would carry +`repo: "my-app"`. diff --git a/gitnexus-claude-plugin/skills/gitnexus-exploring/mcp.json b/gitnexus-claude-plugin/skills/gitnexus-exploring/mcp.json index 4fe6590dd..1af6255fb 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-exploring/mcp.json +++ b/gitnexus-claude-plugin/skills/gitnexus-exploring/mcp.json @@ -2,7 +2,7 @@ "mcpServers": { "gitnexus": { "command": "npx", - "args": ["-y", "gitnexus@1.6.9", "mcp"] + "args": ["-y", "gitnexus@1.6.10", "mcp"] } } } diff --git a/gitnexus-claude-plugin/skills/gitnexus-guide/mcp.json b/gitnexus-claude-plugin/skills/gitnexus-guide/mcp.json index 4fe6590dd..1af6255fb 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-guide/mcp.json +++ b/gitnexus-claude-plugin/skills/gitnexus-guide/mcp.json @@ -2,7 +2,7 @@ "mcpServers": { "gitnexus": { "command": "npx", - "args": ["-y", "gitnexus@1.6.9", "mcp"] + "args": ["-y", "gitnexus@1.6.10", "mcp"] } } } diff --git a/gitnexus-claude-plugin/skills/gitnexus-impact-analysis/SKILL.md b/gitnexus-claude-plugin/skills/gitnexus-impact-analysis/SKILL.md index 2e34f86f6..85d90c90d 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-impact-analysis/SKILL.md +++ b/gitnexus-claude-plugin/skills/gitnexus-impact-analysis/SKILL.md @@ -14,13 +14,42 @@ description: "Use when the user wants to know what will break if they change som - Before making non-trivial code changes - Before committing — to understand what your changes affect +## Bind the repository first + +Impact analysis is the gate that authorizes an edit, so it must answer for the +repository you are about to edit. + +Call `list_repos {}` before the first tool call. With one indexed repository, +use the examples below as written. With more than one, pass `repo` on every +call: an omitted `repo` normally errors, but under an MCP policy with a +configured default it resolves to that default silently. If you cannot tell +which repository is meant, stop and ask — every result below an ambiguous +identity inherits the ambiguity. `list_repos` is paginated, so page with +`offset: pagination.nextOffset` until `hasMore` is false before concluding a +repository is absent. + +`detect_changes` takes `worktree` when your changes are in a linked worktree +the MCP server was not launched from. The server auto-detects a worktree only +when it was launched from inside one; otherwise `git diff` runs in the wrong +checkout and reports zero changed symbols — a false clean check that carries +none of the degradation flags described below. In the CLI fallbacks, `--repo .` +means the current checkout; pass the intended repository path instead when you +are not standing in it. + +State the bound identity with your risk report: + +``` +Repository: () Worktree: Index: , behind HEAD +``` + ## Workflow ``` +0. list_repos {} → Bind repo (and worktree) 1. impact({target: "X", direction: "upstream"}) or `node .gitnexus/run.cjs impact "X" --direction upstream --repo .` 2. READ gitnexus://repo/{name}/processes → Check affected execution flows 3. detect_changes({scope: "all"}) or `node .gitnexus/run.cjs detect-changes --scope all --repo .` -4. Assess risk and report to user +4. Assess risk and report to user, echoing repo/worktree/index identity ``` > If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal. @@ -29,12 +58,14 @@ description: "Use when the user wants to know what will break if they change som ## Checklist ``` +- [ ] list_repos {} — bind repo; explicit repo when >1 indexed, ask if ambiguous - [ ] impact({target, direction: "upstream"}) or CLI fallback to find dependents - [ ] Review d=1 items first (these WILL BREAK) - [ ] Check high-confidence (>0.8) dependencies - [ ] READ processes to check affected execution flows - [ ] detect_changes({scope: "all"}) or CLI fallback for pre-commit check -- [ ] Assess risk level and report to user +- [ ] Confirm the checkout you edited is the checkout that was diffed +- [ ] Assess risk level and report, stating repo/worktree/index identity ``` ## Understanding Output @@ -62,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 --direction upstream --repo .` instead: @@ -69,6 +109,7 @@ treating the symbol as safe to change or delete. ``` impact({ target: "validateUser", + repo: "my-app", // required once >1 repository is indexed direction: "upstream", minConfidence: 0.8, maxDepth: 3 @@ -92,10 +133,26 @@ detect_changes({scope: "all"}) → Risk: MEDIUM ``` +Add `repo` once more than one repository is indexed, and `worktree: ""` when your changes are in a linked worktree the server was not launched +from. + +`partial: true` (a graph query failed) or `truncated: true` (the changed-symbol +listing was capped) means the result is short of the truth, and reads like +`UNKNOWN` above: a zero there means unseen, not unaffected. Re-run it rather +than tick the pre-commit check. + +A wrong-worktree zero carries neither flag and is shape-identical to a genuine +clean result, so confirm the checkout you edited is the one that was diffed +before treating an empty change set as a passed check. + ## Example: "What breaks if I change validateUser?" ``` -1. impact({target: "validateUser", direction: "upstream"}) or `node .gitnexus/run.cjs impact "validateUser" --direction upstream --repo .` +0. list_repos {} + → total: 2 (my-app, billing-api) — both define validateUser, so bind explicitly + +1. impact({target: "validateUser", repo: "my-app", direction: "upstream"}) or `node .gitnexus/run.cjs impact "validateUser" --direction upstream --repo .` → d=1: loginHandler, apiMiddleware (WILL BREAK) → d=2: authRouter, sessionManager (LIKELY AFFECTED) @@ -103,4 +160,8 @@ detect_changes({scope: "all"}) → LoginFlow and TokenRefresh touch validateUser 3. Risk: 2 direct callers, 2 processes = MEDIUM + Repository: my-app (/abs/path/my-app) Worktree: same Index: current ``` + +With a single indexed repository, step 0 returns `total: 1` and the `repo` +argument drops out of every call above. diff --git a/gitnexus-claude-plugin/skills/gitnexus-impact-analysis/mcp.json b/gitnexus-claude-plugin/skills/gitnexus-impact-analysis/mcp.json index 4fe6590dd..1af6255fb 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-impact-analysis/mcp.json +++ b/gitnexus-claude-plugin/skills/gitnexus-impact-analysis/mcp.json @@ -2,7 +2,7 @@ "mcpServers": { "gitnexus": { "command": "npx", - "args": ["-y", "gitnexus@1.6.9", "mcp"] + "args": ["-y", "gitnexus@1.6.10", "mcp"] } } } diff --git a/gitnexus-claude-plugin/skills/gitnexus-lfg/mcp.json b/gitnexus-claude-plugin/skills/gitnexus-lfg/mcp.json index 4fe6590dd..1af6255fb 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-lfg/mcp.json +++ b/gitnexus-claude-plugin/skills/gitnexus-lfg/mcp.json @@ -2,7 +2,7 @@ "mcpServers": { "gitnexus": { "command": "npx", - "args": ["-y", "gitnexus@1.6.9", "mcp"] + "args": ["-y", "gitnexus@1.6.10", "mcp"] } } } diff --git a/gitnexus-claude-plugin/skills/gitnexus-plan/README.md b/gitnexus-claude-plugin/skills/gitnexus-plan/README.md index f7fe58ab9..153374bb7 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-plan/README.md +++ b/gitnexus-claude-plugin/skills/gitnexus-plan/README.md @@ -124,12 +124,17 @@ phase that needs them. statement-level claims (never reconstructs fake edges). - No GitNexus at all → fallback mode: targeted grep/read exploration, findings labelled **source-derived**, with a recommendation to index. -- Reading or publishing a plan requires Linux `/proc/self/fd`, `O_DIRECTORY`, - and `O_NOFOLLOW`; publication also requires a validated absolute Python 3 - PATH candidate with libc `renameat2(RENAME_NOREPLACE)` support, a - writable target repository, and a shared filesystem for the plan and - Git-admin vault. The writer fails closed when those guarantees are - unavailable; it never redirects the plan elsewhere. +- Reading or publishing a plan requires `O_DIRECTORY` and `O_NOFOLLOW`, plus + `/proc/self/fd` on Linux; every other platform is refused. No interpreter is + spawned and no native code is loaded. Publication is `link(2)`, which fails + rather than replaces when the destination name is taken. Linux resolves every + name against a held descriptor, so a parent swapped mid-write cannot redirect + the operation; macOS has no equivalent path and instead pins each directory + with an open descriptor and re-proves the chain either side of every step, + which detects such a swap and aborts. Publishing also needs a writable target + repository and a shared filesystem for the plan and Git-admin vault. The + writer fails closed when those guarantees are unavailable; it never redirects + the plan elsewhere. ## Limitations diff --git a/gitnexus-claude-plugin/skills/gitnexus-plan/mcp.json b/gitnexus-claude-plugin/skills/gitnexus-plan/mcp.json index 4fe6590dd..1af6255fb 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-plan/mcp.json +++ b/gitnexus-claude-plugin/skills/gitnexus-plan/mcp.json @@ -2,7 +2,7 @@ "mcpServers": { "gitnexus": { "command": "npx", - "args": ["-y", "gitnexus@1.6.9", "mcp"] + "args": ["-y", "gitnexus@1.6.10", "mcp"] } } } diff --git a/gitnexus-claude-plugin/skills/gitnexus-plan/references/evidence-provenance.md b/gitnexus-claude-plugin/skills/gitnexus-plan/references/evidence-provenance.md index c686599da..3df5a046d 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-plan/references/evidence-provenance.md +++ b/gitnexus-claude-plugin/skills/gitnexus-plan/references/evidence-provenance.md @@ -98,8 +98,11 @@ excluded. ## Safe existing-plan read contract -`read-plan` fails closed unless Linux `/proc/self/fd`, `O_DIRECTORY`, and -`O_NOFOLLOW` are available. It resolves the exact Git top-level, opens the +`read-plan` fails closed unless the host platform can resolve names against a +held directory descriptor: Linux `/proc/self/fd` with `O_DIRECTORY` and +`O_NOFOLLOW`, or macOS `O_DIRECTORY`/`O_NOFOLLOW`. Every other platform is +refused outright — an unverified read is not a degraded read, it is a different, +racy operation. It resolves the exact Git top-level, opens the repository root and every plan parent as held no-follow directory descriptors, rejects missing, symlink, non-directory, and escaping parents, and opens the leaf with `O_NOFOLLOW`. It reads at most 16 MiB from that held file descriptor, @@ -109,13 +112,17 @@ Neither Deepen nor work may parse bytes obtained before or outside this receipt. ## Safe generated-plan write contract -The writer fails closed unless Linux `/proc/self/fd`, `O_DIRECTORY`, -`O_NOFOLLOW`, and Python 3 with libc `renameat2(RENAME_NOREPLACE)` support are -available. Python may live in `/usr/local`, a Nix profile, or another absolute -PATH directory, but the helper accepts only a resolved executable and -containing directory owned by root or the current user and not writable by -group/other. The resolved executable is opened without following links and -invoked through that held descriptor. Relative PATH entries are ignored. The plan parent and the +The writer fails closed unless the host platform offers `O_DIRECTORY` and +`O_NOFOLLOW`, plus `/proc/self/fd` on Linux. It spawns no interpreter and loads +no native code: publication is `link(2)`, which is atomic, fails `EEXIST` when +the destination name is taken, and refuses a symlinked destination without +following it — the same no-replace guarantee `renameat2(RENAME_NOREPLACE)` and +`renameatx_np(RENAME_EXCL)` provide, available through `fs.linkSync` on every +supported platform. The temporary name is unlinked once the link succeeds; the +published file is the same inode the writer created and verified, so every +identity check downstream holds by construction. A link that succeeds followed +by an unlink that fails leaves the plan published and is reported as success, +because it is one. The plan parent and the repository's Git-admin directory must also share a filesystem. It resolves the target repository's exact Git top-level, opens that root and every destination parent as held no-follow directory descriptors, creates missing @@ -128,15 +135,45 @@ The writer creates a random exclusive temporary file relative to the held final parent descriptor and keeps its no-follow descriptor open. It writes and flushes the bytes, binds the temporary name to the opened inode, and hashes the open file before publication. Immediately before publication it revalidates -the parent and the temporary path, inode, size, and digest. Publication uses an -atomic no-replace move relative to the held directory descriptor. Initial mode -therefore cannot overwrite a destination that appears after the absent check. +the parent and the temporary path, inode, size, and digest. Publication links +the temporary name to the destination relative to the held directory +descriptor, which fails rather than replaces if the destination is taken. +Initial mode therefore cannot overwrite a destination that appears after the +absent check. The writer then flushes the directory and revalidates the committed path by opening it with `O_NOFOLLOW`, hashing both the original temporary fd and the path-bound fd, and performing a second descriptor-anchored path identity check after hashing. A detected mutation or replacement aborts instead of accepting mixed-era output. +### Linux anchors, macOS verifies + +The two platforms reach the same destination by different proofs, and the +difference is real enough to state rather than smooth over. + +On Linux every name resolves through `/proc/self/fd//`, a magic link +the kernel resolves against the inode the descriptor already holds. The names +above it are never re-walked, so an attacker who renames a parent between the +check and the use cannot redirect the operation. The race is impossible, not +merely detected. + +macOS has no such path. `/dev/fd/` is a devfs node, not a magic link: it can +be opened, but nothing can be resolved through it. `open("/dev/fd//child")` +returns `ENOENT`, and `realpath` of it returns `/dev/fd/` rather than the +directory's path — measured on macOS 26, not inferred. Node exposes no `openat`, +no `dir_fd` parameter, and no FFI, so on macOS the writer resolves names +lexically with `O_NOFOLLOW` at every component, holds an open descriptor on +every directory in the chain for the whole operation, and proves before *and* +after each step that the chain still names exactly the inodes it is holding. +Holding the descriptors is what makes the recorded inode numbers trustworthy: +an open descriptor pins its inode, so a freed number cannot be recycled beneath +the walk. + +What that buys is detection rather than prevention. A parent swapped inside the +window between a check and its use is caught by the check that follows, and the +operation aborts having written nothing — but on Linux it could not have +happened at all. No published byte escapes verification on either platform. + `--replace` accepts only a pre-existing regular file and is reserved for Deepen; without it, accidental overwrite is rejected. It also requires the exact canonical `generated_plan_path` and `plan_digest` from the same session's diff --git a/gitnexus-claude-plugin/skills/gitnexus-plan/scripts/evidence-provenance.mjs b/gitnexus-claude-plugin/skills/gitnexus-plan/scripts/evidence-provenance.mjs index 181d2120b..793fe4cd8 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-plan/scripts/evidence-provenance.mjs +++ b/gitnexus-claude-plugin/skills/gitnexus-plan/scripts/evidence-provenance.mjs @@ -479,11 +479,11 @@ function resolveOwnGitTopLevel(absolute) { if (result.status !== 0) return null; let topLevel; try { - topLevel = fs.realpathSync(decodeUtf8(result.stdout, 'nested repository root').trim()); + topLevel = fs.realpathSync.native(decodeUtf8(result.stdout, 'nested repository root').trim()); } catch { return null; } - return topLevel === fs.realpathSync(absolute) ? topLevel : null; + return topLevel === fs.realpathSync.native(absolute) ? topLevel : null; } function readOwnGitlinkHead(absolute) { @@ -616,17 +616,30 @@ function filesystemObject(absolute, expectedKind, mutationGuards, testHooks) { throw new Error(`Unsupported filesystem object at ${absolute}`); } -function guardPathParents(repo, repoPath, mutationGuards) { +// Every dirty path re-walks its own parents, and dirty paths overwhelmingly +// share them — the repository root is re-stat'ed once per path. `guarded` is +// per-snapshot and remembers which absolute directories already carry a guard, +// so each distinct directory is stat'ed and guarded exactly once. +// +// Keeping the first-seen identity is the conservative choice: verifyGuards +// re-checks every guard against the filesystem at the end, so a directory that +// changes after it was guarded still fails there. Skipping a re-stat cannot hide +// a change; it only avoids recording the same directory twice. +function guardPathParents(repo, repoPath, mutationGuards, guarded) { const components = repoPath.split('/'); let current = repo; - const rootStat = fs.lstatSync(repo, { bigint: true }); - mutationGuards.push({ - type: 'directory', - absolute: repo, - identity: stableDirectoryIdentity(rootStat), - }); + if (!guarded.has(repo)) { + guarded.add(repo); + mutationGuards.push({ + type: 'directory', + absolute: repo, + identity: stableDirectoryIdentity(fs.lstatSync(repo, { bigint: true })), + }); + } for (const component of components.slice(0, -1)) { current = path.join(current, component); + // Already proved a real directory and already guarded on an earlier path. + if (guarded.has(current)) continue; let stat; try { stat = fs.lstatSync(current, { bigint: true }); @@ -638,6 +651,7 @@ function guardPathParents(repo, repoPath, mutationGuards) { throw new Error(`Refusing to traverse symlink parent for ${repoPath}`); } if (!stat.isDirectory()) return; + guarded.add(current); mutationGuards.push({ type: 'directory', absolute: current, @@ -646,81 +660,153 @@ function guardPathParents(repo, repoPath, mutationGuards) { } } -function recordAnchoredAbsence(repo, repoPath, mutationGuards) { - requireDescriptorAnchoring(); - const flags = - fs.constants.O_RDONLY | - fs.constants.O_DIRECTORY | - fs.constants.O_NOFOLLOW | - (fs.constants.O_CLOEXEC ?? 0); - const descriptors = []; - let retainedFd; - try { - let currentFd = fs.openSync(repo, flags); - descriptors.push(currentFd); - const components = repoPath.split('/'); - for (let index = 0; index < components.length; index += 1) { - const component = components[index]; - const child = descriptorPath(currentFd, component); - let childStat; - try { - childStat = fs.lstatSync(child, { bigint: true }); - } catch (error) { - if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') throw error; - const parentStat = fs.fstatSync(currentFd, { bigint: true }); - if (!parentStat.isDirectory()) { - throw new Error(`Absence parent is no longer a directory for ${repoPath}`); - } - retainedFd = currentFd; - mutationGuards.push({ - type: 'absence', - fd: retainedFd, - childName: component, - repoPath, - parentIdentity: stableDirectoryIdentity(parentStat), - parentMutationIdentity: statIdentity(parentStat), - }); - for (const fd of descriptors) { - if (fd !== retainedFd) fs.closeSync(fd); - } - return; - } - if (index === components.length - 1) { - throw new Error(`${repoPath} appeared while its absence was being anchored`); - } - if (childStat.isSymbolicLink() || !childStat.isDirectory()) { - throw new Error(`Refusing a non-directory parent while anchoring absence for ${repoPath}`); - } - const nextFd = fs.openSync(child, flags); - descriptors.push(nextFd); - currentFd = nextFd; - } - throw new Error(`Could not anchor absence for ${repoPath}`); - } catch (error) { - for (const fd of descriptors) { - if (fd === retainedFd) continue; - try { - fs.closeSync(fd); - } catch { - // Preserve the primary absence-anchoring error. - } - } - throw error; +// A bound, not a bug: the absence cache deduplicates correctly and leaks nothing, +// but citedPaths is caller-supplied and unbounded, so a pathological snapshot +// could hold more descriptors than the process is allowed (macOS +// kern.maxfilesperproc is 24576). The peak precedes a `git` spawn, so exhaustion +// would surface as a git failure misreported as evidence instability. +// +// Refuse rather than evict: closing a cached descriptor would silently break the +// pinned chain of an absence guard that was already recorded against it, which is +// exactly the inode-recycling hole the pins exist to close. +const ABSENCE_ANCHOR_LIMITS = Object.freeze({ maxPinnedDirectories: 4096 }); + +// Every no-follow read and every exclusive create in this file uses one of these +// two, so a change lands in one place rather than in seven. +const VERIFIED_READ_FLAGS = + fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_CLOEXEC ?? 0); +const VERIFIED_CREATE_FLAGS = + fs.constants.O_RDWR | + fs.constants.O_CREAT | + fs.constants.O_EXCL | + fs.constants.O_NOFOLLOW | + (fs.constants.O_CLOEXEC ?? 0); + +function requireAbsenceAnchorCapacity(cache) { + if (cache.size >= ABSENCE_ANCHOR_LIMITS.maxPinnedDirectories) { + throw new Error( + `Absence anchoring exceeds ${ABSENCE_ANCHOR_LIMITS.maxPinnedDirectories} pinned directories`, + ); } } -function materializeRecord(repo, statusRecord, layers, mutationGuards, testHooks) { +const ANCHORED_DIRECTORY_FLAGS = + fs.constants.O_RDONLY | + fs.constants.O_DIRECTORY | + fs.constants.O_NOFOLLOW | + (fs.constants.O_CLOEXEC ?? 0); + +// Every absence receipt is verified long after its walk returns, so the chain +// that produced it has to stay pinned until the snapshot ends — an unpinned inode +// number can be recycled by a replacement directory that then reproduces the +// recorded identity exactly. Absent cited paths overwhelmingly share prefixes, so +// the walked directories are cached per snapshot and keyed by repo-relative +// prefix: one open descriptor and one anchored walk per distinct directory rather +// than per path. snapshotEvidence owns every descriptor in this cache and closes +// each exactly once; guards only borrow them for verification. +function anchoredAbsenceRoot(repo, cache) { + const cached = cache.get(''); + if (cached) return cached; + requireAbsenceAnchorCapacity(cache); + const fd = openVerifiedDirectory(repo, ANCHORED_DIRECTORY_FLAGS); + const handle = { + fd, + expectedPath: repo, + chain: [ + { expectedPath: repo, identity: stableDirectoryIdentity(fs.fstatSync(fd, { bigint: true })) }, + ], + descriptors: [fd], + }; + cache.set('', handle); + return handle; +} + +function recordAnchoredAbsence(repo, repoPath, mutationGuards, cache) { + requireDescriptorAnchoring(); + const components = repoPath.split('/'); + let handle = anchoredAbsenceRoot(repo, cache); + let prefix = ''; + for (let index = 0; index < components.length; index += 1) { + const component = components[index]; + const isFinal = index === components.length - 1; + prefix = prefix === '' ? component : `${prefix}/${component}`; + // The final component is always re-checked against the filesystem: it is the + // one whose absence is being recorded, and a cached answer would be a stale + // one. Only the prefix directories are reused. + const cached = isFinal ? undefined : cache.get(prefix); + if (cached) { + handle = cached; + continue; + } + const child = anchoredChild(handle, component); + let childStat; + try { + childStat = lstatChild(child); + } catch (error) { + if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') throw error; + const parentStat = fs.fstatSync(handle.fd, { bigint: true }); + if (!parentStat.isDirectory()) { + throw new Error(`Absence parent is no longer a directory for ${repoPath}`); + } + mutationGuards.push({ + type: 'absence', + // The handle is the holder the guard verifies against, and `ref` is the + // child path already built through the anchoredChild chokepoint — the + // guard must never re-derive that name itself. + handle, + ref: child, + fd: handle.fd, + repoPath, + parentMutationIdentity: statIdentity(parentStat), + }); + return; + } + if (isFinal) { + throw new Error(`${repoPath} appeared while its absence was being anchored`); + } + if (childStat.isSymbolicLink() || !childStat.isDirectory()) { + throw new Error(`Refusing a non-directory parent while anchoring absence for ${repoPath}`); + } + requireAbsenceAnchorCapacity(cache); + const childFd = openVerifiedDirectory(child.path, ANCHORED_DIRECTORY_FLAGS); + const expectedPath = path.join(handle.expectedPath, component); + let next; + try { + if (!anchoringBackend().descriptorMatchesChild(childFd, expectedPath, childStat)) { + throw new Error( + `Absence parent descriptor does not match its verified inode for ${repoPath}`, + ); + } + next = { + fd: childFd, + expectedPath, + chain: [...handle.chain, { expectedPath, identity: stableDirectoryIdentity(childStat) }], + descriptors: [...handle.descriptors, childFd], + }; + } catch (error) { + fs.closeSync(childFd); + throw error; + } + cache.set(prefix, next); + handle = next; + } + throw new Error(`Could not anchor absence for ${repoPath}`); +} + +function materializeRecord(repo, statusRecord, layers, mutationGuards, testHooks, walkState) { const head = layers.head(statusRecord.path); const index = layers.index(statusRecord.path); const expectedKind = index.kind === 'gitlink' || head.kind === 'gitlink' ? 'gitlink' : null; - guardPathParents(repo, statusRecord.path, mutationGuards); + guardPathParents(repo, statusRecord.path, mutationGuards, walkState.guardedDirectories); const filesystem = filesystemObject( path.join(repo, ...statusRecord.path.split('/')), expectedKind, mutationGuards, testHooks, ); - if (filesystem.kind === ABSENT) recordAnchoredAbsence(repo, statusRecord.path, mutationGuards); + if (filesystem.kind === ABSENT) { + recordAnchoredAbsence(repo, statusRecord.path, mutationGuards, walkState.absenceCache); + } if (statusRecord.directory_hint && filesystem.kind !== 'directory') { throw new Error( `Git reported an embedded directory but found ${filesystem.kind}: ${statusRecord.path}`, @@ -789,9 +875,15 @@ export function serializeDirtyRecords(entries) { } function assertRepository(repoInput) { - const repo = fs.realpathSync(requireString(repoInput, 'repo')); + // realpathSync.native, not realpathSync: the JS resolver preserves a Windows + // 8.3 short component (C:\Users\RUNNER~1\...) while git always reports the long + // form, so the two would never compare equal and every caller would be told the + // worktree root is not the worktree root it just named. + const repo = fs.realpathSync.native(requireString(repoInput, 'repo')); const topLevelResult = git(repo, ['rev-parse', '--show-toplevel']); - const topLevel = fs.realpathSync(decodeUtf8(topLevelResult.stdout, 'repository root').trim()); + const topLevel = fs.realpathSync.native( + decodeUtf8(topLevelResult.stdout, 'repository root').trim(), + ); if (topLevel !== repo) throw new Error(`--repo must be the Git worktree root (${topLevel})`); return repo; } @@ -882,17 +974,48 @@ function stableFileIdentity(stat) { return [stat.dev, stat.ino, stat.mode, stat.size].map(String).join(':'); } +// The two backends below differ in one decisive way, and it is worth stating +// plainly because the security properties are not the same. +// +// Linux ANCHORS. A name is resolved through /proc/self/fd//, which +// starts the walk at the inode the descriptor holds, so a parent that is renamed +// away cannot be traversed at all: the descriptor keeps pointing at the original +// directory and the impostor planted at the same name is simply never reached. +// +// macOS VERIFIES. Node cannot resolve a name relative to a descriptor there — +// /dev/fd/ is not a magic link (it stats as the directory but every attempt +// to traverse a child through it returns ENOENT), and fcntl F_GETPATH is a +// name-cache snapshot rather than a live anchor. So the Darwin backend resolves +// lexically, holds an open descriptor on every element of the chain, and proves +// before and after each operation that the path chain still names exactly the +// inodes it is holding. That DETECTS a swapped parent and aborts the write; it +// does not make the swap impossible the way the Linux path does. A swap landing +// inside the window between a check and the call it guards is caught by the +// following check, after the fact, rather than being unreachable. +// +// Every other platform gets neither and is refused outright. function requireDescriptorAnchoring() { - if ( - process.platform !== 'linux' || - fs.constants.O_DIRECTORY === undefined || - fs.constants.O_NOFOLLOW === undefined || - !fs.existsSync('/proc/self/fd') - ) { - throw new Error( - 'Safe generated-plan writes require Linux /proc/self/fd and O_DIRECTORY/O_NOFOLLOW; refusing an unanchored write', - ); + const directoryFlagsAvailable = + fs.constants.O_DIRECTORY !== undefined && fs.constants.O_NOFOLLOW !== undefined; + if (process.platform === 'linux') { + if (!directoryFlagsAvailable || !fs.existsSync('/proc/self/fd')) { + throw new Error( + 'Safe generated-plan writes require Linux /proc/self/fd and O_DIRECTORY/O_NOFOLLOW; refusing an unanchored write', + ); + } + return; } + if (process.platform === 'darwin') { + if (!directoryFlagsAvailable) { + throw new Error( + 'Safe generated-plan writes require macOS O_DIRECTORY/O_NOFOLLOW; refusing an unverified write', + ); + } + return; + } + throw new Error( + `Safe generated-plan writes require Linux /proc/self/fd or macOS O_DIRECTORY/O_NOFOLLOW; ${process.platform} offers neither, so refusing an unanchored write`, + ); } function descriptorPath(fd, childName) { @@ -900,157 +1023,352 @@ function descriptorPath(fd, childName) { return childName === undefined ? base : path.join(base, childName); } -function externalDescriptorPath(fd, childName) { - const base = `/proc/${process.pid}/fd/${fd}`; - return childName === undefined ? base : path.join(base, childName); +// Directory opens are plain O_RDONLY|O_DIRECTORY|O_NOFOLLOW|O_CLOEXEC on both +// platforms, and deliberately nothing else. +// +// O_NOFOLLOW_ANY (macOS 11+) used to be ORed in here on the theory that XNU +// ignores unrecognized open flag bits, so it would be inert where unsupported. +// That was wrong: combined with O_DIRECTORY macOS rejects it outright with +// EINVAL, and every directory open on Darwin failed. It is gone and is not +// coming back behind a probe or a degrade-on-EINVAL path — the per-component +// O_NOFOLLOW walk is what delivers the guarantee. Rust's cap-std, the closest +// reference implementation of this problem, has not adopted O_NOFOLLOW_ANY +// either (their issue #179 is still open). +function openVerifiedDirectory(absolute, flags) { + return fs.openSync(absolute, flags); } -const RENAME_NOREPLACE_SCRIPT = String.raw` -import ctypes -import errno -import os -import sys - -libc = ctypes.CDLL(None, use_errno=True) -try: - renameat2 = libc.renameat2 -except AttributeError: - print("libc does not expose renameat2", file=sys.stderr) - raise SystemExit(125) - -renameat2.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint] -renameat2.restype = ctypes.c_int -result = renameat2(-100, os.fsencode(sys.argv[1]), -100, os.fsencode(sys.argv[2]), 1) -if result != 0: - error_number = ctypes.get_errno() - error_name = errno.errorcode.get(error_number, "UNKNOWN") - print(f"renameat2 RENAME_NOREPLACE failed: {error_name}: {os.strerror(error_number)}", file=sys.stderr) - raise SystemExit(17 if error_number == errno.EEXIST else 126) -`; - -let atomicMoverPath; - -function spawnHeldExecutable(executable, args, options) { - const before = fs.fstatSync(executable.fd, { bigint: true }); - if (!before.isFile() || statIdentity(before) !== executable.identity) { - throw new Error('Validated Python executable changed before invocation'); - } - const result = spawnSync('/proc/self/fd/3', args, { - ...options, - stdio: ['ignore', 'pipe', 'pipe', executable.fd], - }); - const after = fs.fstatSync(executable.fd, { bigint: true }); - assertStableIdentity(before, after, 'validated Python executable'); - return result; +// File opens additionally get O_NONBLOCK, which directory opens do not need: +// it stops a FIFO swapped in at the target name from wedging the process on +// open. The identity comparison that follows rejects the FIFO anyway, but only +// if we ever get as far as running it. +function openVerifiedFile(absolute, flags, mode) { + const nonBlocking = flags | (fs.constants.O_NONBLOCK ?? 0); + return mode === undefined + ? fs.openSync(absolute, nonBlocking) + : fs.openSync(absolute, nonBlocking, mode); } -function validatedPathExecutable(candidate) { - if (!path.isAbsolute(candidate)) return null; - const candidateDirectory = path.dirname(candidate); - let resolvedDirectory; - let resolved; - let directoryStats; - let executableStat; +// The publish primitive, identical on both platforms. +// +// link() is the portable no-replace publish: it fails with EEXIST if the +// destination name is taken — by a regular file, by a directory, or by a symlink, +// live or dangling — and it never follows that symlink to clobber its target. +// It also works where renameat2(RENAME_NOREPLACE) does not, notably v9fs, which +// is why the WSL2 9p case that used to fail every time now works. +// +// The published file is the same inode as the temporary, so every identity +// comparison the callers already make still holds, and validateCommittedPlan +// becomes strictly stronger: it compares the destination against the exact inode +// whose bytes were fsynced. +// +// On Linux both paths are /proc/self/fd//, so the publish is anchored +// to the held parent descriptors exactly like every other operation. +// link(2) BUGS: "On NFS filesystems, the return code may be wrong in case the NFS +// server performs the link creation and dies before it can say so. Use stat(2) to +// find out if the link got created." open(2) NOTES gives the remedy this +// implements: on a reported failure, stat the source and see whether its link +// count reached 2. A false positive would need someone to have hardlinked a +// 16-random-byte name inside a directory we hold open — and validateCommittedPlan +// still proves the destination is the exact temporary inode afterwards. +function linkCreatedDespiteError(sourcePath) { try { - resolvedDirectory = fs.realpathSync(candidateDirectory); - resolved = fs.realpathSync(candidate); - const resolvedExecutableDirectory = fs.realpathSync(path.dirname(resolved)); - directoryStats = [...new Set([resolvedDirectory, resolvedExecutableDirectory])].map( - (directory) => fs.statSync(directory), - ); - executableStat = fs.lstatSync(resolved); - fs.accessSync(resolved, fs.constants.X_OK); + return fs.statSync(sourcePath, { bigint: true }).nlink === 2n; } catch { - return null; + return false; } - if ( - directoryStats.some((stat) => !stat.isDirectory()) || - !executableStat.isFile() || - executableStat.isSymbolicLink() - ) { - return null; - } - const uid = typeof process.getuid === 'function' ? process.getuid() : null; - const trustedOwner = (stat) => uid === null || stat.uid === 0 || stat.uid === uid; - if ( - directoryStats.some((stat) => !trustedOwner(stat) || (stat.mode & 0o022) !== 0) || - !trustedOwner(executableStat) || - (executableStat.mode & 0o022) !== 0 - ) { - return null; - } - return resolved; } -function resolveAtomicMover() { - if (atomicMoverPath) return atomicMoverPath; - const candidates = new Set(); - for (const entry of (process.env.PATH ?? '').split(path.delimiter)) { - if (entry && path.isAbsolute(entry)) candidates.add(path.join(entry, 'python3')); - } - for (const entry of ['/usr/local/bin/python3', '/usr/bin/python3', '/bin/python3']) { - candidates.add(entry); - } - for (const candidate of candidates) { - const resolved = validatedPathExecutable(candidate); - if (!resolved) continue; - let fd; - try { - fd = fs.openSync( - resolved, - fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_CLOEXEC ?? 0), - ); - } catch { - continue; +function linkNoReplace(sourcePath, destinationPath) { + try { + fs.linkSync(sourcePath, destinationPath); + } catch (error) { + // Callers treat "destination taken" as a distinct outcome, not a failure. + if (error?.code === 'EEXIST') return false; + if (!linkCreatedDespiteError(sourcePath)) { + // FAT, Coda, and some SMB/FUSE/virtiofs mounts have no hardlinks at all. + // Git falls back to rename here, but git can afford to lose collision + // detection because its objects are content-addressed; a plan destination + // is a plain name, so a replacing rename would silently clobber whatever + // is already there. Refuse loudly instead. + if (error?.code === 'EPERM' || error?.code === 'ENOTSUP' || error?.code === 'EMLINK') { + throw new Error( + `Generated-plan publication requires hard links, which this filesystem refused (${error.code}); refusing to fall back to a replacing rename`, + ); + } + throw error; } - const opened = fs.fstatSync(fd, { bigint: true }); - const executable = { fd, identity: statIdentity(opened), resolved }; - const version = spawnHeldExecutable( - executable, - ['-I', '-S', '-c', 'import sys; print(sys.version_info[0])'], - { - encoding: 'utf8', - env: { ...process.env, LANG: 'C', LC_ALL: 'C' }, - timeout: 10_000, - windowsHide: true, - }, - ); - if (version.status === 0 && version.stdout.trim() === '3') { - atomicMoverPath = executable; - return executable; - } - fs.closeSync(fd); } - throw new Error( - 'Safe generated-plan publication requires a trusted absolute Python 3 PATH candidate with libc renameat2 support', - ); -} - -function atomicMoveNoReplace(source, destination) { - const mover = resolveAtomicMover(); - const result = spawnHeldExecutable( - mover, - ['-I', '-S', '-c', RENAME_NOREPLACE_SCRIPT, source, destination], - { - encoding: 'utf8', - env: { ...process.env, LANG: 'C', LC_ALL: 'C' }, - timeout: 10_000, - windowsHide: true, - }, - ); - if (result.error) throw result.error; - if (result.status === 17) return false; - if (result.status !== 0) { - throw new Error( - `Atomic no-replace move failed (${result.status}): ${(result.stderr ?? '').trim()}`, - ); + try { + fs.unlinkSync(sourcePath); + } catch { + // The link succeeded, so the plan IS published. A temporary name left behind + // is a stray file, not an unpublished plan: reporting it as a failure would + // be a lie, and rolling back would unpublish a plan that is already live. } return true; } -function lstatOptional(absolute) { +// A directory holder is anything that owns a verified chain: a plan-parent +// handle, a ref's parent directory, or an absence guard. Two arrays describe it, +// both root-first and the same length — `chain` records each element's expected +// path and dev/ino/mode, and `descriptors` holds an open descriptor on each. +// +// Holding those descriptors is load-bearing rather than decorative. dev/ino/mode +// is unique only among *live* inodes: an inode number freed by an rmdir is handed +// straight back to the next mkdir, so a replacement directory can reproduce a +// recorded identity exactly. An open descriptor pins the inode, so the number +// cannot be recycled for as long as the holder exists. +function verifyPinnedDescriptors(holder) { + const { chain, descriptors } = holder; + if (!Array.isArray(descriptors) || descriptors.length !== chain.length) { + throw new Error('Generated-plan parent chain is missing the descriptors that pin it'); + } + chain.forEach((item, index) => { + const pinned = fs.fstatSync(descriptors[index], { bigint: true }); + if (!pinned.isDirectory() || stableDirectoryIdentity(pinned) !== item.identity) { + throw new Error('Generated-plan parent descriptor changed during the write'); + } + }); +} + +function verifyLexicalChain(holder) { + for (const item of holder.chain) { + let lexical; + try { + lexical = fs.lstatSync(item.expectedPath, { bigint: true }); + } catch (error) { + if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') throw error; + // A parent renamed out from under us is a mismatch, not a missing file: + // reporting the raw ENOENT would leak an unrelated-looking error out of a + // check whose whole job is to say the chain no longer holds. + throw new Error('Generated-plan lexical parent no longer matches its directory descriptor'); + } + if ( + lexical.isSymbolicLink() || + !lexical.isDirectory() || + stableDirectoryIdentity(lexical) !== item.identity + ) { + throw new Error('Generated-plan lexical parent no longer matches its directory descriptor'); + } + } +} + +// The whole platform seam, in five methods. Everything else an operation does is +// identical on both platforms and lives in the shared functions below. +// +// Only two things actually differ: how a name becomes a path, and what guard +// wraps the operation that uses it. +// +// Linux ANCHORS. /proc/self/fd// starts the walk at the inode the +// descriptor holds, so a parent renamed away cannot be traversed at all and the +// guard is a no-op — there is nothing left to verify. +// +// macOS VERIFIES. It resolves lexically, so before and after every operation it +// proves that each element of the path chain still names the exact inode being +// held for it. That DETECTS a swapped parent and aborts; it does not make the +// swap impossible. A swap landing inside the window is caught by the trailing +// check, after the fact, rather than being unreachable. The check runs after a +// failure too, because a verdict observed through a chain that has since changed +// is not a verdict. +const LINUX_ANCHORING = { + childPath(dirHandle, childName) { + return descriptorPath(dirHandle.fd, childName); + }, + verified(holders, run) { + return run(); + }, + descriptorMatchesChild(fd, expectedPath) { + return fs.realpathSync.native(descriptorPath(fd)) === expectedPath; + }, + parentStillResolves(parentHandle) { + return fs.realpathSync.native(descriptorPath(parentHandle.fd)) === parentHandle.expectedPath; + }, + verifyAbsentChild(guard) { + if (absentChildIsPresent(guard.ref)) { + throw new Error(`${guard.repoPath} appeared before evidence materialization completed`); + } + }, +}; + +const DARWIN_ANCHORING = { + childPath(dirHandle, childName) { + return path.join(dirHandle.expectedPath, childName); + }, + verified(holders, run) { + const list = Array.isArray(holders) ? holders : [holders]; + const proveChain = () => { + for (const holder of list) { + verifyPinnedDescriptors(holder); + verifyLexicalChain(holder); + } + }; + proveChain(); + let value; + try { + value = run(); + } catch (error) { + proveChain(); + throw error; + } + proveChain(); + return value; + }, + descriptorMatchesChild(fd, _expectedPath, childStat) { + // There is no live fd-to-path oracle on macOS (F_GETPATH is a name-cache + // snapshot, not an anchor), so escape is decided the other way round: the + // name was just resolved under a verified chain, and the descriptor opened + // from it counts only if it is that same inode. + const opened = fs.fstatSync(fd, { bigint: true }); + return ( + opened.isDirectory() && stableDirectoryIdentity(opened) === stableDirectoryIdentity(childStat) + ); + }, + parentStillResolves(parentHandle) { + // Both halves are needed: a directory renamed away keeps its inode, so the + // descriptors alone still match and only the lexical half notices it moved. + try { + verifyPinnedDescriptors(parentHandle); + verifyLexicalChain(parentHandle); + } catch { + return false; + } + return true; + }, + verifyAbsentChild(guard) { + let present; + try { + present = DARWIN_ANCHORING.verified(guard.handle, () => absentChildIsPresent(guard.ref)); + } catch (error) { + // A chain that no longer holds makes the absence verdict meaningless, and + // the caller reports that as the anchor changing rather than as a stray + // parent-descriptor error. Linux cannot reach this: its guard is a no-op. + throw new Error( + `Absence anchor changed for ${guard.repoPath}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + if (present) { + throw new Error(`${guard.repoPath} appeared before evidence materialization completed`); + } + }, +}; + +const ANCHORING_BACKENDS = new Map([ + ['linux', LINUX_ANCHORING], + ['darwin', DARWIN_ANCHORING], +]); + +function anchoringBackend() { + const backend = ANCHORING_BACKENDS.get(process.platform); + if (!backend) { + // requireDescriptorAnchoring normally refuses first; this is the same answer + // from the other side, so an unsupported platform can never fall through to + // whichever backend happened to be the ternary's default. + throw new Error( + `No generated-plan anchoring backend for ${process.platform}; refusing an unanchored write`, + ); + } + return backend; +} + +// Open, fstat, compare, close on mismatch. The descriptor never escapes this +// function unless it refers to the inode the caller already verified by name, so +// a lexical open that landed anywhere else cannot be used by accident. On Linux +// the comparison passes trivially — the /proc walk already resolved from the +// held parent — and costs one fstat to keep the guarantee structural rather than +// dependent on which backend is in play. +function adoptVerifiedFile(ref, expectedStat, flags) { + const fd = openVerifiedFile(ref.path, flags); + let opened; try { - return fs.lstatSync(absolute, { bigint: true }); + opened = fs.fstatSync(fd, { bigint: true }); + } catch (error) { + fs.closeSync(fd); + throw error; + } + if (stableFileIdentity(opened) !== stableFileIdentity(expectedStat)) { + fs.closeSync(fd); + return null; + } + return fd; +} + +function absentChildIsPresent(ref) { + try { + fs.lstatSync(ref.path, { bigint: true }); + } catch (error) { + if (error?.code === 'ENOENT') return false; + throw error; + } + return true; +} + +// The operations. Each is the same on both platforms; only the guard differs. +function lstatChild(ref) { + return anchoringBackend().verified(ref.dir, () => fs.lstatSync(ref.path, { bigint: true })); +} + +function openChildRead(ref, flags, expectedStat) { + return anchoringBackend().verified(ref.dir, () => { + const fd = adoptVerifiedFile(ref, expectedStat, flags); + if (fd === null) { + throw new Error(`${ref.name} was replaced between its verified stat and its no-follow open`); + } + return fd; + }); +} + +function createChild(ref, flags, mode) { + // O_CREAT|O_EXCL|O_NOFOLLOW is atomic at the leaf, so the only thing the guard + // has to cover is which directory the leaf landed in. + return anchoringBackend().verified(ref.dir, () => openVerifiedFile(ref.path, flags, mode)); +} + +function mkdirChild(ref, mode) { + anchoringBackend().verified(ref.dir, () => fs.mkdirSync(ref.path, { mode })); +} + +function publishNoReplace(sourceRef, destinationRef) { + return anchoringBackend().verified([sourceRef.dir, destinationRef.dir], () => + linkNoReplace(sourceRef.path, destinationRef.path), + ); +} + +// The single place a name becomes a path, and therefore the right place to +// enforce that a name is one ordinary component. +// +// A trailing separator is the sharp edge here, not a tidiness concern: +// open(path, O_NOFOLLOW) FOLLOWS a symlink when path ends in "/" — the trap +// behind CVE-2026-39822 / golang/go#79005, which let os.Root escape its own +// root. path.join preserves that trailing slash, so a component carrying one +// would turn every no-follow open in this file into a following one. +// normalizeRepoPath already rejects such components upstream; this is the +// chokepoint that makes it true for every caller, including the generated +// temporary and vault names that never pass through it. +function anchoredChild(dirHandle, childName) { + if ( + typeof childName !== 'string' || + childName === '' || + childName === '.' || + childName === '..' || + childName.includes('/') || + childName.includes('\\') || + childName.includes('\0') + ) { + throw new Error(`Refusing to resolve ${JSON.stringify(childName)} as a single path component`); + } + return { + dir: dirHandle, + name: childName, + path: anchoringBackend().childPath(dirHandle, childName), + }; +} + +function lstatAnchoredOptional(ref) { + try { + return lstatChild(ref); } catch (error) { if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return null; throw error; @@ -1063,39 +1381,37 @@ function openPlanParent( { createMissing = true, purpose = 'Generated-plan' } = {}, ) { requireDescriptorAnchoring(); - const flags = - fs.constants.O_RDONLY | - fs.constants.O_DIRECTORY | - fs.constants.O_NOFOLLOW | - (fs.constants.O_CLOEXEC ?? 0); + // Root-first and index-aligned with `chain`: verifyPinnedDescriptors relies on + // that, and the descriptors are what pin each recorded inode against reuse. const descriptors = []; try { - let currentFd = fs.openSync(repo, flags); + let currentFd = openVerifiedDirectory(repo, ANCHORED_DIRECTORY_FLAGS); descriptors.push(currentFd); const rootStat = fs.fstatSync(currentFd, { bigint: true }); const chain = [{ expectedPath: repo, identity: stableDirectoryIdentity(rootStat) }]; + let currentHandle = { fd: currentFd, expectedPath: repo, chain, descriptors }; const traversed = []; for (const component of parentComponents) { traversed.push(component); - const anchoredChild = descriptorPath(currentFd, component); + const child = anchoredChild(currentHandle, component); let childStat; let created = false; try { - childStat = fs.lstatSync(anchoredChild, { bigint: true }); + childStat = lstatChild(child); } catch (error) { if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') throw error; if (!createMissing) { throw new Error(`${purpose} parent does not exist: ${traversed.join('/')}`); } - fs.mkdirSync(anchoredChild, { mode: 0o755 }); - childStat = fs.lstatSync(anchoredChild, { bigint: true }); + mkdirChild(child, 0o755); + childStat = lstatChild(child); created = true; } if (childStat.isSymbolicLink() || !childStat.isDirectory()) { throw new Error(`${purpose} parent is not a real directory: ${traversed.join('/')}`); } const parentFd = currentFd; - const childFd = fs.openSync(anchoredChild, flags); + const childFd = openVerifiedDirectory(child.path, ANCHORED_DIRECTORY_FLAGS); descriptors.push(childFd); currentFd = childFd; if (created) { @@ -1103,18 +1419,16 @@ function openPlanParent( fs.fsyncSync(parentFd); } const expected = path.join(repo, ...traversed); - const actual = fs.realpathSync(descriptorPath(currentFd)); - if (actual !== expected) { + if (!anchoringBackend().descriptorMatchesChild(currentFd, expected, childStat)) { throw new Error(`${purpose} parent escaped the repository: ${traversed.join('/')}`); } const openedStat = fs.fstatSync(currentFd, { bigint: true }); chain.push({ expectedPath: expected, identity: stableDirectoryIdentity(openedStat) }); + currentHandle = { fd: currentFd, expectedPath: expected, chain, descriptors }; } - const stat = fs.fstatSync(currentFd, { bigint: true }); return { descriptors, fd: currentFd, - identity: stableDirectoryIdentity(stat), expectedPath: path.join(repo, ...parentComponents), chain, }; @@ -1134,9 +1448,16 @@ function closeDescriptors(descriptors) { } } +// A handle's identity IS its chain leaf's identity. Storing it twice meant two +// fstats a line apart and a re-stamp helper to keep them agreeing; deriving it +// removes both. +function handleIdentity(handle) { + return handle.chain[handle.chain.length - 1].identity; +} + function resolveGitDirectory(repo) { const result = git(repo, ['rev-parse', '--absolute-git-dir']); - return fs.realpathSync(decodeUtf8(result.stdout, 'Git administrative directory').trim()); + return fs.realpathSync.native(decodeUtf8(result.stdout, 'Git administrative directory').trim()); } function openBackupVault(repo, { createMissing = true } = {}) { @@ -1147,9 +1468,12 @@ function openBackupVault(repo, { createMissing = true } = {}) { }); fs.fchmodSync(handle.fd, 0o700); fs.fsyncSync(handle.fd); - const stat = fs.fstatSync(handle.fd, { bigint: true }); - handle.identity = stableDirectoryIdentity(stat); - handle.chain[handle.chain.length - 1].identity = handle.identity; + // mode is part of every directory identity, so hardening the vault changes the + // identity the chain recorded for it; without this the next verification would + // reject the directory it just hardened. + handle.chain[handle.chain.length - 1].identity = stableDirectoryIdentity( + fs.fstatSync(handle.fd, { bigint: true }), + ); return { ...handle, gitDirectory }; } @@ -1157,33 +1481,28 @@ function validatePlanParent(parentHandle) { const descriptorStat = fs.fstatSync(parentHandle.fd, { bigint: true }); if ( !descriptorStat.isDirectory() || - stableDirectoryIdentity(descriptorStat) !== parentHandle.identity + stableDirectoryIdentity(descriptorStat) !== handleIdentity(parentHandle) ) { throw new Error('Generated-plan parent descriptor changed during the write'); } - const descriptorRealPath = fs.realpathSync(descriptorPath(parentHandle.fd)); - if (descriptorRealPath !== parentHandle.expectedPath) { + if (!anchoringBackend().parentStillResolves(parentHandle)) { throw new Error('Generated-plan parent moved or was replaced during the write'); } - for (const item of parentHandle.chain) { - const lexicalStat = fs.lstatSync(item.expectedPath, { bigint: true }); - if ( - lexicalStat.isSymbolicLink() || - !lexicalStat.isDirectory() || - stableDirectoryIdentity(lexicalStat) !== item.identity - ) { - throw new Error('Generated-plan lexical parent no longer matches its directory descriptor'); - } - } + // Both halves come from the shared helpers rather than being restated here: an + // earlier hand-copy of the lexical loop lost verifyLexicalChain's ENOENT/ENOTDIR + // translation, so a renamed parent could surface a raw errno from a function + // with a dozen call sites. + verifyPinnedDescriptors(parentHandle); + verifyLexicalChain(parentHandle); } function inspectPlanDestination( - finalPath, + finalRef, { replace, expectedIdentity, mustBeAbsent = false } = {}, ) { let stat; try { - stat = fs.lstatSync(finalPath, { bigint: true }); + stat = lstatChild(finalRef); } catch (error) { if (error?.code === 'ENOENT') { if (expectedIdentity) throw new Error('Generated plan disappeared during the write'); @@ -1201,19 +1520,17 @@ function inspectPlanDestination( if (expectedIdentity && identity !== expectedIdentity) { throw new Error('Generated plan changed during the write'); } - return identity; + return stat; } -function openExistingPlanDestination(finalPath, replace) { - const identity = inspectPlanDestination(finalPath, { replace }); - if (identity === null) { +function openExistingPlanDestination(finalRef, replace) { + const stat = inspectPlanDestination(finalRef, { replace }); + if (stat === null) { if (replace) throw new Error('Deepen mode requires an existing generated plan to replace'); return { fd: undefined, identity: null, stableIdentity: null }; } - const fd = fs.openSync( - finalPath, - fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_CLOEXEC ?? 0), - ); + const identity = statIdentity(stat); + const fd = openChildRead(finalRef, VERIFIED_READ_FLAGS, stat); try { const opened = fs.fstatSync(fd, { bigint: true }); if (!opened.isFile() || statIdentity(opened) !== identity) { @@ -1264,8 +1581,8 @@ function hashOpenFile(fd, label) { }; } -function validateCommittedPlan(finalPath, tempFd, expectedTemp, testHooks) { - const before = fs.lstatSync(finalPath, { bigint: true }); +function validateCommittedPlan(finalRef, tempFd, expectedTemp, testHooks) { + const before = lstatChild(finalRef); if ( before.isSymbolicLink() || !before.isFile() || @@ -1273,19 +1590,16 @@ function validateCommittedPlan(finalPath, tempFd, expectedTemp, testHooks) { ) { throw new Error('Generated-plan destination failed its first post-write identity check'); } - const finalFd = fs.openSync( - finalPath, - fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_CLOEXEC ?? 0), - ); + const finalFd = openChildRead(finalRef, VERIFIED_READ_FLAGS, before); try { const opened = fs.fstatSync(finalFd, { bigint: true }); if (!opened.isFile() || stableFileIdentity(opened) !== expectedTemp.identity) { throw new Error('Generated-plan destination changed while its no-follow descriptor opened'); } - testHooks?.afterFinalOpen?.({ fd: finalFd, finalPath }); + testHooks?.afterFinalOpen?.({ fd: finalFd, finalPath: finalRef.path }); const committedViaTemp = hashOpenFile(tempFd, 'generated-plan committed file'); const committedViaPath = hashOpenFile(finalFd, 'generated-plan destination descriptor'); - const after = fs.lstatSync(finalPath, { bigint: true }); + const after = lstatChild(finalRef); const openedAfter = fs.fstatSync(finalFd, { bigint: true }); if ( after.isSymbolicLink() || @@ -1320,22 +1634,19 @@ function copyOpenFile(sourceFd, destinationFd, label) { return after; } -function openVerifiedPathFile(absolute, label) { - const before = fs.lstatSync(absolute, { bigint: true }); +function openVerifiedAnchoredFile(ref, label, knownStat) { + const before = knownStat ?? lstatChild(ref); if (before.isSymbolicLink() || !before.isFile()) { throw new Error(`${label} is not a regular no-follow file`); } - const fd = fs.openSync( - absolute, - fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_CLOEXEC ?? 0), - ); + const fd = openChildRead(ref, VERIFIED_READ_FLAGS, before); try { const opened = fs.fstatSync(fd, { bigint: true }); if (!opened.isFile() || stableFileIdentity(opened) !== stableFileIdentity(before)) { throw new Error(`${label} changed while its descriptor opened`); } const layer = hashOpenFile(fd, label); - const after = fs.lstatSync(absolute, { bigint: true }); + const after = lstatChild(ref); if (after.isSymbolicLink() || !after.isFile() || stableFileIdentity(after) !== layer.identity) { throw new Error(`${label} changed after verification`); } @@ -1358,10 +1669,10 @@ export function readPlanSafely({ repo: repoInput, generatedPlanPath, testHooks } let fd; try { validatePlanParent(parentHandle); - const finalPath = descriptorPath(parentHandle.fd, finalName); + const finalRef = anchoredChild(parentHandle, finalName); let before; try { - before = fs.lstatSync(finalPath, { bigint: true }); + before = lstatChild(finalRef); } catch (error) { if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') { throw new Error(`Loaded plan does not exist: ${generatedPlan}`); @@ -1371,15 +1682,12 @@ export function readPlanSafely({ repo: repoInput, generatedPlanPath, testHooks } if (before.isSymbolicLink() || !before.isFile()) { throw new Error('Loaded plan must be a regular file, never a symlink'); } - fd = fs.openSync( - finalPath, - fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_CLOEXEC ?? 0), - ); + fd = openChildRead(finalRef, VERIFIED_READ_FLAGS, before); const opened = fs.fstatSync(fd, { bigint: true }); if (!opened.isFile() || statIdentity(opened) !== statIdentity(before)) { throw new Error('Loaded plan changed while its no-follow descriptor opened'); } - testHooks?.afterPlanOpen?.({ fd, finalPath }); + testHooks?.afterPlanOpen?.({ fd, finalPath: finalRef.path }); const chunks = []; let total = 0; const buffer = Buffer.allocUnsafe(64 * 1024); @@ -1394,7 +1702,7 @@ export function readPlanSafely({ repo: repoInput, generatedPlanPath, testHooks } decodeUtf8(contents, 'loaded plan'); const after = fs.fstatSync(fd, { bigint: true }); assertStableIdentity(opened, after, 'loaded plan'); - const pathAfter = fs.lstatSync(finalPath, { bigint: true }); + const pathAfter = lstatChild(finalRef); if ( pathAfter.isSymbolicLink() || !pathAfter.isFile() || @@ -1419,24 +1727,22 @@ function artifactGitPath(name) { return `gitnexus-plan-backups/${name}`; } -function verifyVaultArtifactFromFreshRoot(repo, gitPath, expectedLayer) { - const components = gitPath.split('/'); - if (components.length !== 2 || components[0] !== 'gitnexus-plan-backups') { - throw new Error(`Invalid Git-admin artifact path: ${gitPath}`); - } +function verifyVaultArtifactFromFreshRoot(repo, name, expectedLayer) { const freshVault = openBackupVault(repo, { createMissing: false }); try { validatePlanParent(freshVault); - const opened = openVerifiedPathFile( - descriptorPath(freshVault.fd, components[1]), - `Git-admin artifact ${gitPath}`, + const opened = openVerifiedAnchoredFile( + anchoredChild(freshVault, name), + `Git-admin artifact ${artifactGitPath(name)}`, ); try { if ( opened.layer.identity !== expectedLayer.identity || opened.layer.digest !== expectedLayer.digest ) { - throw new Error(`Git-admin artifact changed before fresh-root verification: ${gitPath}`); + throw new Error( + `Git-admin artifact changed before fresh-root verification: ${artifactGitPath(name)}`, + ); } } finally { fs.closeSync(opened.fd); @@ -1449,16 +1755,8 @@ function verifyVaultArtifactFromFreshRoot(repo, gitPath, expectedLayer) { function createVaultCopyFromFd(repo, vault, sourceFd, role) { validatePlanParent(vault); const name = `.gitnexus-plan-${role}-${process.pid}-${randomBytes(16).toString('hex')}.bak`; - const absolute = descriptorPath(vault.fd, name); - const destinationFd = fs.openSync( - absolute, - fs.constants.O_RDWR | - fs.constants.O_CREAT | - fs.constants.O_EXCL | - fs.constants.O_NOFOLLOW | - (fs.constants.O_CLOEXEC ?? 0), - 0o600, - ); + const artifact = anchoredChild(vault, name); + const destinationFd = createChild(artifact, VERIFIED_CREATE_FLAGS, 0o600); let destination; try { const sourceStat = copyOpenFile(sourceFd, destinationFd, role); @@ -1469,7 +1767,7 @@ function createVaultCopyFromFd(repo, vault, sourceFd, role) { if (source.size !== destination.size || source.digest !== destination.digest) { throw new Error(`${role} vault copy does not match its held source descriptor`); } - const pathStat = fs.lstatSync(absolute, { bigint: true }); + const pathStat = lstatChild(artifact); if ( pathStat.isSymbolicLink() || !pathStat.isFile() || @@ -1481,24 +1779,15 @@ function createVaultCopyFromFd(repo, vault, sourceFd, role) { } finally { fs.closeSync(destinationFd); } - const gitPath = artifactGitPath(name); - verifyVaultArtifactFromFreshRoot(repo, gitPath, destination); - return { role, gitPath, layer: destination }; + verifyVaultArtifactFromFreshRoot(repo, name, destination); + return { role, gitPath: artifactGitPath(name), layer: destination }; } function createVaultCopyFromBytes(repo, vault, contents, role) { validatePlanParent(vault); const name = `.gitnexus-plan-${role}-${process.pid}-${randomBytes(16).toString('hex')}.bak`; - const absolute = descriptorPath(vault.fd, name); - const fd = fs.openSync( - absolute, - fs.constants.O_RDWR | - fs.constants.O_CREAT | - fs.constants.O_EXCL | - fs.constants.O_NOFOLLOW | - (fs.constants.O_CLOEXEC ?? 0), - 0o600, - ); + const artifact = anchoredChild(vault, name); + const fd = createChild(artifact, VERIFIED_CREATE_FLAGS, 0o600); let layer; try { writeAll(fd, contents); @@ -1508,7 +1797,7 @@ function createVaultCopyFromBytes(repo, vault, contents, role) { if (layer.size !== BigInt(contents.length) || layer.digest !== sha256(contents)) { throw new Error(`${role} vault copy does not match the intended plan bytes`); } - const pathStat = fs.lstatSync(absolute, { bigint: true }); + const pathStat = lstatChild(artifact); if ( pathStat.isSymbolicLink() || !pathStat.isFile() || @@ -1520,32 +1809,31 @@ function createVaultCopyFromBytes(repo, vault, contents, role) { } finally { fs.closeSync(fd); } - const gitPath = artifactGitPath(name); - verifyVaultArtifactFromFreshRoot(repo, gitPath, layer); - return { role, gitPath, layer }; + verifyVaultArtifactFromFreshRoot(repo, name, layer); + return { role, gitPath: artifactGitPath(name), layer }; } function movePathToVault(repo, sourceHandle, sourceName, vault, role) { - const source = descriptorPath(sourceHandle.fd, sourceName); - if (!lstatOptional(source)) return null; + const source = anchoredChild(sourceHandle, sourceName); + if (!lstatAnchoredOptional(source)) return null; const name = `.gitnexus-plan-${role}-${process.pid}-${randomBytes(16).toString('hex')}.bak`; - const destination = descriptorPath(vault.fd, name); - const moved = atomicMoveNoReplace( - externalDescriptorPath(sourceHandle.fd, sourceName), - externalDescriptorPath(vault.fd, name), - ); + const destination = anchoredChild(vault, name); + const moved = publishNoReplace(source, destination); if (!moved) throw new Error(`${role} preservation destination unexpectedly exists`); fs.fsyncSync(sourceHandle.fd); if (vault.fd !== sourceHandle.fd) fs.fsyncSync(vault.fd); - const sourceAfter = lstatOptional(source); - const destinationAfter = lstatOptional(destination); + const sourceAfter = lstatAnchoredOptional(source); + const destinationAfter = lstatAnchoredOptional(destination); if (sourceAfter || !destinationAfter) { throw new Error(`${role} could not be atomically moved into the Git-admin vault`); } - const opened = openVerifiedPathFile(destination, `${role} Git-admin artifact`); - const gitPath = artifactGitPath(name); - verifyVaultArtifactFromFreshRoot(repo, gitPath, opened.layer); - return { role, gitPath, layer: opened.layer, fd: opened.fd }; + const opened = openVerifiedAnchoredFile( + destination, + `${role} Git-admin artifact`, + destinationAfter, + ); + verifyVaultArtifactFromFreshRoot(repo, name, opened.layer); + return { role, gitPath: artifactGitPath(name), layer: opened.layer, fd: opened.fd }; } function formatPreservedArtifacts(artifacts) { @@ -1600,10 +1888,10 @@ export function writePlanSafely({ const finalName = components.pop(); let parentHandle; let vaultHandle; - let tempPath; + let tempRef; let tempName; let tempFd; - let finalPath; + let finalRef; let expectedTemp; let originalDestination; let priorBackup; @@ -1611,7 +1899,6 @@ export function writePlanSafely({ try { parentHandle = openPlanParent(repo, components); vaultHandle = openBackupVault(repo); - resolveAtomicMover(); const parentDevice = fs.fstatSync(parentHandle.fd, { bigint: true }).dev; const vaultDevice = fs.fstatSync(vaultHandle.fd, { bigint: true }).dev; if (parentDevice !== vaultDevice) { @@ -1622,19 +1909,11 @@ export function writePlanSafely({ testHooks?.afterParentOpen?.({ fd: parentHandle.fd, path: parentHandle.expectedPath }); validatePlanParent(parentHandle); validatePlanParent(vaultHandle); - finalPath = descriptorPath(parentHandle.fd, finalName); - originalDestination = openExistingPlanDestination(finalPath, shouldReplace); + finalRef = anchoredChild(parentHandle, finalName); + originalDestination = openExistingPlanDestination(finalRef, shouldReplace); tempName = `.gitnexus-plan-${process.pid}-${randomBytes(16).toString('hex')}.tmp`; - tempPath = descriptorPath(parentHandle.fd, tempName); - tempFd = fs.openSync( - tempPath, - fs.constants.O_RDWR | - fs.constants.O_CREAT | - fs.constants.O_EXCL | - fs.constants.O_NOFOLLOW | - (fs.constants.O_CLOEXEC ?? 0), - 0o600, - ); + tempRef = anchoredChild(parentHandle, tempName); + tempFd = createChild(tempRef, VERIFIED_CREATE_FLAGS, 0o600); writeAll(tempFd, contents); fs.fchmodSync(tempFd, 0o644); fs.fsyncSync(tempFd); @@ -1646,12 +1925,12 @@ export function writePlanSafely({ testHooks?.beforeRename?.({ fd: parentHandle.fd, path: parentHandle.expectedPath, - tempPath, + tempPath: tempRef.path, }); validatePlanParent(parentHandle); validatePlanParent(vaultHandle); validateOpenPlanDestination(originalDestination); - const tempPathStat = fs.lstatSync(tempPath, { bigint: true }); + const tempPathStat = lstatChild(tempRef); const currentTemp = hashOpenFile(tempFd, 'generated-plan temporary file'); if ( tempPathStat.isSymbolicLink() || @@ -1664,7 +1943,7 @@ export function writePlanSafely({ } if (shouldReplace) { - testHooks?.beforeBackupMove?.({ fd: parentHandle.fd, finalPath }); + testHooks?.beforeBackupMove?.({ fd: parentHandle.fd, finalPath: finalRef.path }); const originalLayer = hashOpenFile(originalDestination.fd, 'prior generated plan'); if (originalLayer.digest !== expectedDigest) { throw new Error( @@ -1673,7 +1952,7 @@ export function writePlanSafely({ } validatePlanParent(parentHandle); validateOpenPlanDestination(originalDestination); - inspectPlanDestination(finalPath, { + inspectPlanDestination(finalRef, { replace: true, expectedIdentity: originalDestination.identity, }); @@ -1691,20 +1970,20 @@ export function writePlanSafely({ ); throw new Error('Destination raced while the prior plan was moved into preservation'); } - if (lstatOptional(finalPath)) { + if (lstatAnchoredOptional(finalRef)) { throw new Error('Destination reappeared after the prior plan was preserved'); } } testHooks?.beforePublication?.({ fd: parentHandle.fd, - finalPath, - tempPath, + finalPath: finalRef.path, + tempPath: tempRef.path, replace: shouldReplace, }); validatePlanParent(parentHandle); validatePlanParent(vaultHandle); - const finalTempPathStat = fs.lstatSync(tempPath, { bigint: true }); + const finalTempPathStat = lstatChild(tempRef); const finalTemp = hashOpenFile(tempFd, 'generated-plan temporary file'); if ( finalTempPathStat.isSymbolicLink() || @@ -1715,19 +1994,25 @@ export function writePlanSafely({ ) { throw new Error('Generated-plan temporary path or content changed at publication'); } - atomicMoveNoReplace( - externalDescriptorPath(parentHandle.fd, tempName), - externalDescriptorPath(parentHandle.fd, finalName), - ); - if (lstatOptional(tempPath) || !lstatOptional(finalPath)) { + // link() reports the race itself; re-deriving that verdict from a later pair + // of stats would be both slower and weaker. + if (!publishNoReplace(tempRef, finalRef)) { throw new Error('Generated-plan publication was refused because the destination raced'); } + // link() creates a directory entry, so it needs the parent fsync that rename + // needed: the file's own bytes were fsynced through tempFd before this point, + // and this makes the name that now reaches them durable too. Skipping it is + // the step write-file-atomic omits and maildir, git and atomicwrites all + // mandate. + // + // Honest limitation: on macOS fsync is not a write barrier — the durable + // primitive there is fcntl(F_FULLFSYNC), which Node does not expose. A + // macOS plan write is therefore as durable as fsync makes it and no more. fs.fsyncSync(parentHandle.fd); - testHooks?.afterPublication?.({ fd: parentHandle.fd, finalPath }); - testHooks?.afterRename?.({ fd: parentHandle.fd, finalPath }); + testHooks?.afterPublication?.({ fd: parentHandle.fd, finalPath: finalRef.path }); validatePlanParent(parentHandle); validatePlanParent(vaultHandle); - validateCommittedPlan(finalPath, tempFd, expectedTemp, testHooks); + validateCommittedPlan(finalRef, tempFd, expectedTemp, testHooks); const receipt = { generated_plan_path: generatedPlan, bytes_written: contents.length }; if (priorBackup) receipt.prior_plan_backup_git_path = priorBackup.gitPath; return receipt; @@ -1848,6 +2133,11 @@ export function snapshotEvidence({ const headGuards = captureHeadGuards(repo); const dirty = initialDirty.records; const mutationGuards = []; + // Per-snapshot walk state: `absenceCache` owns every descriptor an absence + // anchor holds, deduplicated by repo-relative prefix and closed exactly once + // below; `guardedDirectories` keeps parent guarding to one stat per directory. + const absenceCache = new Map(); + const walkState = { absenceCache, guardedDirectories: new Set() }; try { testHooks?.afterAnchorCapture?.({ headCommit: head }); @@ -1862,7 +2152,9 @@ export function snapshotEvidence({ testHooks?.afterGitLayerLoad?.({ headCommit: head }); const globalEntries = [...dirty.values()] .filter((record) => record.path !== generatedPlan) - .map((record) => materializeRecord(repo, record, layers, mutationGuards, testHooks)); + .map((record) => + materializeRecord(repo, record, layers, mutationGuards, testHooks, walkState), + ); const citedEntries = [...normalizedCitations].sort(compareUtf8).map((repoPath) => { const status = dirty.get(repoPath) ?? { path: repoPath, @@ -1871,7 +2163,7 @@ export function snapshotEvidence({ rename_to: null, has_untracked: false, }; - const entry = materializeRecord(repo, status, layers, mutationGuards, testHooks); + const entry = materializeRecord(repo, status, layers, mutationGuards, testHooks, walkState); const present = Object.values(entry.object_kind).some((kind) => kind !== ABSENT); if (!present) entry.state = ABSENT; else if (entry.state === 'clean' && entry.object_kind.untracked !== ABSENT) { @@ -1906,21 +2198,13 @@ export function snapshotEvidence({ throw new Error(`${guard.absolute} changed before evidence materialization completed`); } } else if (guard.type === 'absence') { + // statIdentity is a strict superset of stableDirectoryIdentity on the + // same stat, so comparing both could only ever fire together. const parent = fs.fstatSync(guard.fd, { bigint: true }); - if ( - !parent.isDirectory() || - stableDirectoryIdentity(parent) !== guard.parentIdentity || - statIdentity(parent) !== guard.parentMutationIdentity - ) { + if (!parent.isDirectory() || statIdentity(parent) !== guard.parentMutationIdentity) { throw new Error(`Absence anchor changed for ${guard.repoPath}`); } - try { - fs.lstatSync(descriptorPath(guard.fd, guard.childName), { bigint: true }); - } catch (error) { - if (error?.code === 'ENOENT') continue; - throw error; - } - throw new Error(`${guard.repoPath} appeared before evidence materialization completed`); + anchoringBackend().verifyAbsentChild(guard); } } for (const guard of headGuards) verifyControlFile(guard); @@ -1955,12 +2239,10 @@ export function snapshotEvidence({ cited_path_manifest: citedEntries, }; } finally { - const closed = new Set(); - for (const guard of mutationGuards) { - if (guard.type !== 'absence' || closed.has(guard.fd)) continue; - closed.add(guard.fd); + // One entry per distinct anchored directory, so one close per descriptor. + for (const handle of absenceCache.values()) { try { - fs.closeSync(guard.fd); + fs.closeSync(handle.fd); } catch { // Preserve the primary snapshot result/error. } diff --git a/gitnexus-claude-plugin/skills/gitnexus-refactoring/SKILL.md b/gitnexus-claude-plugin/skills/gitnexus-refactoring/SKILL.md index 2dbb71ca0..9d63eb6e3 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-refactoring/SKILL.md +++ b/gitnexus-claude-plugin/skills/gitnexus-refactoring/SKILL.md @@ -13,9 +13,32 @@ description: "Use when the user wants to rename, extract, split, move, or restru - "Move this to a new file" - Any task involving renaming, extracting, splitting, or restructuring code +## Bind the repository first + +Refactoring writes to disk. `rename` with `dry_run: false` edits files in +whichever repository was resolved, so binding identity here is a safety gate, +not bookkeeping. + +Call `list_repos {}` before the first tool call. With one indexed repository, +use the examples below as written. With more than one, pass `repo` on every +call: an omitted `repo` normally errors, but under an MCP policy with a +configured default it resolves to that default silently. If you cannot tell +which repository is meant, stop and ask. Never run `rename` with +`dry_run: false` until the preview in the same bound repository has been +reviewed — its returned `file_path` values show which checkout is about to be +written, so read them as a confirmation of identity. + +`list_repos` is paginated, so page with `offset: pagination.nextOffset` until +`hasMore` is false before concluding a repository is absent. + +`detect_changes` takes `worktree` when you are editing a linked worktree the +MCP server was not launched from; otherwise `git diff` runs in the wrong +checkout and reports nothing changed, which reads as a verified refactor. + ## Workflow ``` +0. list_repos {} → Bind repo (and worktree) 1. impact({target: "X", direction: "upstream"}) → Map all dependents 2. query({search_query: "X"}) → Find execution flows involving X 3. context({name: "X"}) → See all incoming/outgoing refs @@ -29,7 +52,9 @@ description: "Use when the user wants to rename, extract, split, move, or restru ### Rename Symbol ``` +- [ ] list_repos {} — bind repo; explicit repo when >1 indexed, ask if ambiguous - [ ] rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits +- [ ] Confirm the previewed file paths are in the bound repository/worktree - [ ] Review graph edits (high confidence) and text_search edits (review carefully) - [ ] If satisfied: rename({..., dry_run: false}) — apply edits - [ ] detect_changes() — verify only expected files changed @@ -39,6 +64,7 @@ description: "Use when the user wants to rename, extract, split, move, or restru ### Extract Module ``` +- [ ] list_repos {} — bind repo; explicit repo when >1 indexed, ask if ambiguous - [ ] context({name: target}) — see all incoming/outgoing refs - [ ] impact({target, direction: "upstream"}) — find all external callers - [ ] Define new module interface @@ -50,6 +76,7 @@ description: "Use when the user wants to rename, extract, split, move, or restru ### Split Function/Service ``` +- [ ] list_repos {} — bind repo; explicit repo when >1 indexed, ask if ambiguous - [ ] context({name: target}) — understand all callees - [ ] Group callees by responsibility - [ ] impact({target, direction: "upstream"}) — map callers to update @@ -64,7 +91,7 @@ description: "Use when the user wants to rename, extract, split, move, or restru **rename** — automated multi-file rename: ``` -rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true}) +rename({symbol_name: "validateUser", new_name: "authenticateUser", repo: "my-app", dry_run: true}) → 12 edits across 8 files → 10 graph edits (high confidence), 2 text_search edits (review) → Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}] @@ -73,7 +100,7 @@ rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true **impact** — map all dependents first: ``` -impact({target: "validateUser", direction: "upstream"}) +impact({target: "validateUser", repo: "my-app", direction: "upstream"}) → d=1: loginHandler, apiMiddleware, testUtils → Affected Processes: LoginFlow, TokenRefresh ``` @@ -87,6 +114,14 @@ detect_changes({scope: "all"}) → Risk: MEDIUM ``` +`partial: true` (a graph query failed) or `truncated: true` (the changed-symbol +listing was capped) means the result is short of the truth: a short or empty +list is not proof that only the expected files changed. Re-run it rather than +treat the refactor as verified. + +A wrong-worktree zero carries neither flag and is indistinguishable from a +clean verification, so confirm the diffed checkout is the one you edited. + **cypher** — custom reference queries: ```cypher @@ -102,20 +137,28 @@ RETURN caller.name, caller.filePath ORDER BY caller.filePath | Cross-area refs | Use detect_changes after to verify scope | | String/dynamic refs | query to find them | | External/public API | Version and deprecate properly | +| Same name in another indexed repo | Bind `repo`; verify previewed paths before applying | ## Example: Rename `validateUser` to `authenticateUser` ``` -1. rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true}) +0. list_repos {} + → total: 2 (my-app, billing-api) — both define validateUser, so bind explicitly + +1. rename({symbol_name: "validateUser", new_name: "authenticateUser", repo: "my-app", dry_run: true}) → 12 edits: 10 graph (safe), 2 text_search (review) → Files: validator.ts, login.ts, middleware.ts, config.json... 2. Review text_search edits (config.json: dynamic reference!) -3. rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false}) +3. rename({symbol_name: "validateUser", new_name: "authenticateUser", repo: "my-app", dry_run: false}) → Applied 12 edits across 8 files -4. detect_changes({scope: "all"}) +4. detect_changes({scope: "all", repo: "my-app"}) → Affected: LoginFlow, TokenRefresh → Risk: MEDIUM — run tests for these flows + Repository: my-app (/abs/path/my-app) Worktree: same Index: current ``` + +With a single indexed repository, step 0 returns `total: 1` and the `repo` +argument drops out of every call above. diff --git a/gitnexus-claude-plugin/skills/gitnexus-refactoring/mcp.json b/gitnexus-claude-plugin/skills/gitnexus-refactoring/mcp.json index 4fe6590dd..1af6255fb 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-refactoring/mcp.json +++ b/gitnexus-claude-plugin/skills/gitnexus-refactoring/mcp.json @@ -2,7 +2,7 @@ "mcpServers": { "gitnexus": { "command": "npx", - "args": ["-y", "gitnexus@1.6.9", "mcp"] + "args": ["-y", "gitnexus@1.6.10", "mcp"] } } } diff --git a/gitnexus-claude-plugin/skills/gitnexus-review/mcp.json b/gitnexus-claude-plugin/skills/gitnexus-review/mcp.json index 4fe6590dd..1af6255fb 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-review/mcp.json +++ b/gitnexus-claude-plugin/skills/gitnexus-review/mcp.json @@ -2,7 +2,7 @@ "mcpServers": { "gitnexus": { "command": "npx", - "args": ["-y", "gitnexus@1.6.9", "mcp"] + "args": ["-y", "gitnexus@1.6.10", "mcp"] } } } diff --git a/gitnexus-claude-plugin/skills/gitnexus-work/SKILL.md b/gitnexus-claude-plugin/skills/gitnexus-work/SKILL.md index 4f7856ea7..f9baab16a 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-work/SKILL.md +++ b/gitnexus-claude-plugin/skills/gitnexus-work/SKILL.md @@ -216,7 +216,10 @@ Work through plan §7 step by step, in order. For each step: `detect_changes` → commit as one unbroken sequence from the repository root — interleaving other work between the gate and the commit is how the gate gets skipped. Unexpected - affected flows → investigate before committing, not after. + affected flows → investigate before committing, not after. A result + flagged `partial` (a graph query failed) or `truncated` (the symbol + listing was capped) blocks the commit the same way: the gate did not + see every changed symbol, so re-run it rather than read it as clean. A relationship-affecting implementation edit or commit invalidates the procedure's prior proof. The next step must perform the required inter-step diff --git a/gitnexus-claude-plugin/skills/gitnexus-work/mcp.json b/gitnexus-claude-plugin/skills/gitnexus-work/mcp.json index 4fe6590dd..1af6255fb 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-work/mcp.json +++ b/gitnexus-claude-plugin/skills/gitnexus-work/mcp.json @@ -2,7 +2,7 @@ "mcpServers": { "gitnexus": { "command": "npx", - "args": ["-y", "gitnexus@1.6.9", "mcp"] + "args": ["-y", "gitnexus@1.6.10", "mcp"] } } } diff --git a/gitnexus-claude-plugin/skills/gitnexus-work/references/evidence-provenance.md b/gitnexus-claude-plugin/skills/gitnexus-work/references/evidence-provenance.md index c686599da..3df5a046d 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-work/references/evidence-provenance.md +++ b/gitnexus-claude-plugin/skills/gitnexus-work/references/evidence-provenance.md @@ -98,8 +98,11 @@ excluded. ## Safe existing-plan read contract -`read-plan` fails closed unless Linux `/proc/self/fd`, `O_DIRECTORY`, and -`O_NOFOLLOW` are available. It resolves the exact Git top-level, opens the +`read-plan` fails closed unless the host platform can resolve names against a +held directory descriptor: Linux `/proc/self/fd` with `O_DIRECTORY` and +`O_NOFOLLOW`, or macOS `O_DIRECTORY`/`O_NOFOLLOW`. Every other platform is +refused outright — an unverified read is not a degraded read, it is a different, +racy operation. It resolves the exact Git top-level, opens the repository root and every plan parent as held no-follow directory descriptors, rejects missing, symlink, non-directory, and escaping parents, and opens the leaf with `O_NOFOLLOW`. It reads at most 16 MiB from that held file descriptor, @@ -109,13 +112,17 @@ Neither Deepen nor work may parse bytes obtained before or outside this receipt. ## Safe generated-plan write contract -The writer fails closed unless Linux `/proc/self/fd`, `O_DIRECTORY`, -`O_NOFOLLOW`, and Python 3 with libc `renameat2(RENAME_NOREPLACE)` support are -available. Python may live in `/usr/local`, a Nix profile, or another absolute -PATH directory, but the helper accepts only a resolved executable and -containing directory owned by root or the current user and not writable by -group/other. The resolved executable is opened without following links and -invoked through that held descriptor. Relative PATH entries are ignored. The plan parent and the +The writer fails closed unless the host platform offers `O_DIRECTORY` and +`O_NOFOLLOW`, plus `/proc/self/fd` on Linux. It spawns no interpreter and loads +no native code: publication is `link(2)`, which is atomic, fails `EEXIST` when +the destination name is taken, and refuses a symlinked destination without +following it — the same no-replace guarantee `renameat2(RENAME_NOREPLACE)` and +`renameatx_np(RENAME_EXCL)` provide, available through `fs.linkSync` on every +supported platform. The temporary name is unlinked once the link succeeds; the +published file is the same inode the writer created and verified, so every +identity check downstream holds by construction. A link that succeeds followed +by an unlink that fails leaves the plan published and is reported as success, +because it is one. The plan parent and the repository's Git-admin directory must also share a filesystem. It resolves the target repository's exact Git top-level, opens that root and every destination parent as held no-follow directory descriptors, creates missing @@ -128,15 +135,45 @@ The writer creates a random exclusive temporary file relative to the held final parent descriptor and keeps its no-follow descriptor open. It writes and flushes the bytes, binds the temporary name to the opened inode, and hashes the open file before publication. Immediately before publication it revalidates -the parent and the temporary path, inode, size, and digest. Publication uses an -atomic no-replace move relative to the held directory descriptor. Initial mode -therefore cannot overwrite a destination that appears after the absent check. +the parent and the temporary path, inode, size, and digest. Publication links +the temporary name to the destination relative to the held directory +descriptor, which fails rather than replaces if the destination is taken. +Initial mode therefore cannot overwrite a destination that appears after the +absent check. The writer then flushes the directory and revalidates the committed path by opening it with `O_NOFOLLOW`, hashing both the original temporary fd and the path-bound fd, and performing a second descriptor-anchored path identity check after hashing. A detected mutation or replacement aborts instead of accepting mixed-era output. +### Linux anchors, macOS verifies + +The two platforms reach the same destination by different proofs, and the +difference is real enough to state rather than smooth over. + +On Linux every name resolves through `/proc/self/fd//`, a magic link +the kernel resolves against the inode the descriptor already holds. The names +above it are never re-walked, so an attacker who renames a parent between the +check and the use cannot redirect the operation. The race is impossible, not +merely detected. + +macOS has no such path. `/dev/fd/` is a devfs node, not a magic link: it can +be opened, but nothing can be resolved through it. `open("/dev/fd//child")` +returns `ENOENT`, and `realpath` of it returns `/dev/fd/` rather than the +directory's path — measured on macOS 26, not inferred. Node exposes no `openat`, +no `dir_fd` parameter, and no FFI, so on macOS the writer resolves names +lexically with `O_NOFOLLOW` at every component, holds an open descriptor on +every directory in the chain for the whole operation, and proves before *and* +after each step that the chain still names exactly the inodes it is holding. +Holding the descriptors is what makes the recorded inode numbers trustworthy: +an open descriptor pins its inode, so a freed number cannot be recycled beneath +the walk. + +What that buys is detection rather than prevention. A parent swapped inside the +window between a check and its use is caught by the check that follows, and the +operation aborts having written nothing — but on Linux it could not have +happened at all. No published byte escapes verification on either platform. + `--replace` accepts only a pre-existing regular file and is reserved for Deepen; without it, accidental overwrite is rejected. It also requires the exact canonical `generated_plan_path` and `plan_digest` from the same session's diff --git a/gitnexus-claude-plugin/skills/gitnexus-work/scripts/evidence-provenance.mjs b/gitnexus-claude-plugin/skills/gitnexus-work/scripts/evidence-provenance.mjs index 181d2120b..793fe4cd8 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-work/scripts/evidence-provenance.mjs +++ b/gitnexus-claude-plugin/skills/gitnexus-work/scripts/evidence-provenance.mjs @@ -479,11 +479,11 @@ function resolveOwnGitTopLevel(absolute) { if (result.status !== 0) return null; let topLevel; try { - topLevel = fs.realpathSync(decodeUtf8(result.stdout, 'nested repository root').trim()); + topLevel = fs.realpathSync.native(decodeUtf8(result.stdout, 'nested repository root').trim()); } catch { return null; } - return topLevel === fs.realpathSync(absolute) ? topLevel : null; + return topLevel === fs.realpathSync.native(absolute) ? topLevel : null; } function readOwnGitlinkHead(absolute) { @@ -616,17 +616,30 @@ function filesystemObject(absolute, expectedKind, mutationGuards, testHooks) { throw new Error(`Unsupported filesystem object at ${absolute}`); } -function guardPathParents(repo, repoPath, mutationGuards) { +// Every dirty path re-walks its own parents, and dirty paths overwhelmingly +// share them — the repository root is re-stat'ed once per path. `guarded` is +// per-snapshot and remembers which absolute directories already carry a guard, +// so each distinct directory is stat'ed and guarded exactly once. +// +// Keeping the first-seen identity is the conservative choice: verifyGuards +// re-checks every guard against the filesystem at the end, so a directory that +// changes after it was guarded still fails there. Skipping a re-stat cannot hide +// a change; it only avoids recording the same directory twice. +function guardPathParents(repo, repoPath, mutationGuards, guarded) { const components = repoPath.split('/'); let current = repo; - const rootStat = fs.lstatSync(repo, { bigint: true }); - mutationGuards.push({ - type: 'directory', - absolute: repo, - identity: stableDirectoryIdentity(rootStat), - }); + if (!guarded.has(repo)) { + guarded.add(repo); + mutationGuards.push({ + type: 'directory', + absolute: repo, + identity: stableDirectoryIdentity(fs.lstatSync(repo, { bigint: true })), + }); + } for (const component of components.slice(0, -1)) { current = path.join(current, component); + // Already proved a real directory and already guarded on an earlier path. + if (guarded.has(current)) continue; let stat; try { stat = fs.lstatSync(current, { bigint: true }); @@ -638,6 +651,7 @@ function guardPathParents(repo, repoPath, mutationGuards) { throw new Error(`Refusing to traverse symlink parent for ${repoPath}`); } if (!stat.isDirectory()) return; + guarded.add(current); mutationGuards.push({ type: 'directory', absolute: current, @@ -646,81 +660,153 @@ function guardPathParents(repo, repoPath, mutationGuards) { } } -function recordAnchoredAbsence(repo, repoPath, mutationGuards) { - requireDescriptorAnchoring(); - const flags = - fs.constants.O_RDONLY | - fs.constants.O_DIRECTORY | - fs.constants.O_NOFOLLOW | - (fs.constants.O_CLOEXEC ?? 0); - const descriptors = []; - let retainedFd; - try { - let currentFd = fs.openSync(repo, flags); - descriptors.push(currentFd); - const components = repoPath.split('/'); - for (let index = 0; index < components.length; index += 1) { - const component = components[index]; - const child = descriptorPath(currentFd, component); - let childStat; - try { - childStat = fs.lstatSync(child, { bigint: true }); - } catch (error) { - if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') throw error; - const parentStat = fs.fstatSync(currentFd, { bigint: true }); - if (!parentStat.isDirectory()) { - throw new Error(`Absence parent is no longer a directory for ${repoPath}`); - } - retainedFd = currentFd; - mutationGuards.push({ - type: 'absence', - fd: retainedFd, - childName: component, - repoPath, - parentIdentity: stableDirectoryIdentity(parentStat), - parentMutationIdentity: statIdentity(parentStat), - }); - for (const fd of descriptors) { - if (fd !== retainedFd) fs.closeSync(fd); - } - return; - } - if (index === components.length - 1) { - throw new Error(`${repoPath} appeared while its absence was being anchored`); - } - if (childStat.isSymbolicLink() || !childStat.isDirectory()) { - throw new Error(`Refusing a non-directory parent while anchoring absence for ${repoPath}`); - } - const nextFd = fs.openSync(child, flags); - descriptors.push(nextFd); - currentFd = nextFd; - } - throw new Error(`Could not anchor absence for ${repoPath}`); - } catch (error) { - for (const fd of descriptors) { - if (fd === retainedFd) continue; - try { - fs.closeSync(fd); - } catch { - // Preserve the primary absence-anchoring error. - } - } - throw error; +// A bound, not a bug: the absence cache deduplicates correctly and leaks nothing, +// but citedPaths is caller-supplied and unbounded, so a pathological snapshot +// could hold more descriptors than the process is allowed (macOS +// kern.maxfilesperproc is 24576). The peak precedes a `git` spawn, so exhaustion +// would surface as a git failure misreported as evidence instability. +// +// Refuse rather than evict: closing a cached descriptor would silently break the +// pinned chain of an absence guard that was already recorded against it, which is +// exactly the inode-recycling hole the pins exist to close. +const ABSENCE_ANCHOR_LIMITS = Object.freeze({ maxPinnedDirectories: 4096 }); + +// Every no-follow read and every exclusive create in this file uses one of these +// two, so a change lands in one place rather than in seven. +const VERIFIED_READ_FLAGS = + fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_CLOEXEC ?? 0); +const VERIFIED_CREATE_FLAGS = + fs.constants.O_RDWR | + fs.constants.O_CREAT | + fs.constants.O_EXCL | + fs.constants.O_NOFOLLOW | + (fs.constants.O_CLOEXEC ?? 0); + +function requireAbsenceAnchorCapacity(cache) { + if (cache.size >= ABSENCE_ANCHOR_LIMITS.maxPinnedDirectories) { + throw new Error( + `Absence anchoring exceeds ${ABSENCE_ANCHOR_LIMITS.maxPinnedDirectories} pinned directories`, + ); } } -function materializeRecord(repo, statusRecord, layers, mutationGuards, testHooks) { +const ANCHORED_DIRECTORY_FLAGS = + fs.constants.O_RDONLY | + fs.constants.O_DIRECTORY | + fs.constants.O_NOFOLLOW | + (fs.constants.O_CLOEXEC ?? 0); + +// Every absence receipt is verified long after its walk returns, so the chain +// that produced it has to stay pinned until the snapshot ends — an unpinned inode +// number can be recycled by a replacement directory that then reproduces the +// recorded identity exactly. Absent cited paths overwhelmingly share prefixes, so +// the walked directories are cached per snapshot and keyed by repo-relative +// prefix: one open descriptor and one anchored walk per distinct directory rather +// than per path. snapshotEvidence owns every descriptor in this cache and closes +// each exactly once; guards only borrow them for verification. +function anchoredAbsenceRoot(repo, cache) { + const cached = cache.get(''); + if (cached) return cached; + requireAbsenceAnchorCapacity(cache); + const fd = openVerifiedDirectory(repo, ANCHORED_DIRECTORY_FLAGS); + const handle = { + fd, + expectedPath: repo, + chain: [ + { expectedPath: repo, identity: stableDirectoryIdentity(fs.fstatSync(fd, { bigint: true })) }, + ], + descriptors: [fd], + }; + cache.set('', handle); + return handle; +} + +function recordAnchoredAbsence(repo, repoPath, mutationGuards, cache) { + requireDescriptorAnchoring(); + const components = repoPath.split('/'); + let handle = anchoredAbsenceRoot(repo, cache); + let prefix = ''; + for (let index = 0; index < components.length; index += 1) { + const component = components[index]; + const isFinal = index === components.length - 1; + prefix = prefix === '' ? component : `${prefix}/${component}`; + // The final component is always re-checked against the filesystem: it is the + // one whose absence is being recorded, and a cached answer would be a stale + // one. Only the prefix directories are reused. + const cached = isFinal ? undefined : cache.get(prefix); + if (cached) { + handle = cached; + continue; + } + const child = anchoredChild(handle, component); + let childStat; + try { + childStat = lstatChild(child); + } catch (error) { + if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') throw error; + const parentStat = fs.fstatSync(handle.fd, { bigint: true }); + if (!parentStat.isDirectory()) { + throw new Error(`Absence parent is no longer a directory for ${repoPath}`); + } + mutationGuards.push({ + type: 'absence', + // The handle is the holder the guard verifies against, and `ref` is the + // child path already built through the anchoredChild chokepoint — the + // guard must never re-derive that name itself. + handle, + ref: child, + fd: handle.fd, + repoPath, + parentMutationIdentity: statIdentity(parentStat), + }); + return; + } + if (isFinal) { + throw new Error(`${repoPath} appeared while its absence was being anchored`); + } + if (childStat.isSymbolicLink() || !childStat.isDirectory()) { + throw new Error(`Refusing a non-directory parent while anchoring absence for ${repoPath}`); + } + requireAbsenceAnchorCapacity(cache); + const childFd = openVerifiedDirectory(child.path, ANCHORED_DIRECTORY_FLAGS); + const expectedPath = path.join(handle.expectedPath, component); + let next; + try { + if (!anchoringBackend().descriptorMatchesChild(childFd, expectedPath, childStat)) { + throw new Error( + `Absence parent descriptor does not match its verified inode for ${repoPath}`, + ); + } + next = { + fd: childFd, + expectedPath, + chain: [...handle.chain, { expectedPath, identity: stableDirectoryIdentity(childStat) }], + descriptors: [...handle.descriptors, childFd], + }; + } catch (error) { + fs.closeSync(childFd); + throw error; + } + cache.set(prefix, next); + handle = next; + } + throw new Error(`Could not anchor absence for ${repoPath}`); +} + +function materializeRecord(repo, statusRecord, layers, mutationGuards, testHooks, walkState) { const head = layers.head(statusRecord.path); const index = layers.index(statusRecord.path); const expectedKind = index.kind === 'gitlink' || head.kind === 'gitlink' ? 'gitlink' : null; - guardPathParents(repo, statusRecord.path, mutationGuards); + guardPathParents(repo, statusRecord.path, mutationGuards, walkState.guardedDirectories); const filesystem = filesystemObject( path.join(repo, ...statusRecord.path.split('/')), expectedKind, mutationGuards, testHooks, ); - if (filesystem.kind === ABSENT) recordAnchoredAbsence(repo, statusRecord.path, mutationGuards); + if (filesystem.kind === ABSENT) { + recordAnchoredAbsence(repo, statusRecord.path, mutationGuards, walkState.absenceCache); + } if (statusRecord.directory_hint && filesystem.kind !== 'directory') { throw new Error( `Git reported an embedded directory but found ${filesystem.kind}: ${statusRecord.path}`, @@ -789,9 +875,15 @@ export function serializeDirtyRecords(entries) { } function assertRepository(repoInput) { - const repo = fs.realpathSync(requireString(repoInput, 'repo')); + // realpathSync.native, not realpathSync: the JS resolver preserves a Windows + // 8.3 short component (C:\Users\RUNNER~1\...) while git always reports the long + // form, so the two would never compare equal and every caller would be told the + // worktree root is not the worktree root it just named. + const repo = fs.realpathSync.native(requireString(repoInput, 'repo')); const topLevelResult = git(repo, ['rev-parse', '--show-toplevel']); - const topLevel = fs.realpathSync(decodeUtf8(topLevelResult.stdout, 'repository root').trim()); + const topLevel = fs.realpathSync.native( + decodeUtf8(topLevelResult.stdout, 'repository root').trim(), + ); if (topLevel !== repo) throw new Error(`--repo must be the Git worktree root (${topLevel})`); return repo; } @@ -882,17 +974,48 @@ function stableFileIdentity(stat) { return [stat.dev, stat.ino, stat.mode, stat.size].map(String).join(':'); } +// The two backends below differ in one decisive way, and it is worth stating +// plainly because the security properties are not the same. +// +// Linux ANCHORS. A name is resolved through /proc/self/fd//, which +// starts the walk at the inode the descriptor holds, so a parent that is renamed +// away cannot be traversed at all: the descriptor keeps pointing at the original +// directory and the impostor planted at the same name is simply never reached. +// +// macOS VERIFIES. Node cannot resolve a name relative to a descriptor there — +// /dev/fd/ is not a magic link (it stats as the directory but every attempt +// to traverse a child through it returns ENOENT), and fcntl F_GETPATH is a +// name-cache snapshot rather than a live anchor. So the Darwin backend resolves +// lexically, holds an open descriptor on every element of the chain, and proves +// before and after each operation that the path chain still names exactly the +// inodes it is holding. That DETECTS a swapped parent and aborts the write; it +// does not make the swap impossible the way the Linux path does. A swap landing +// inside the window between a check and the call it guards is caught by the +// following check, after the fact, rather than being unreachable. +// +// Every other platform gets neither and is refused outright. function requireDescriptorAnchoring() { - if ( - process.platform !== 'linux' || - fs.constants.O_DIRECTORY === undefined || - fs.constants.O_NOFOLLOW === undefined || - !fs.existsSync('/proc/self/fd') - ) { - throw new Error( - 'Safe generated-plan writes require Linux /proc/self/fd and O_DIRECTORY/O_NOFOLLOW; refusing an unanchored write', - ); + const directoryFlagsAvailable = + fs.constants.O_DIRECTORY !== undefined && fs.constants.O_NOFOLLOW !== undefined; + if (process.platform === 'linux') { + if (!directoryFlagsAvailable || !fs.existsSync('/proc/self/fd')) { + throw new Error( + 'Safe generated-plan writes require Linux /proc/self/fd and O_DIRECTORY/O_NOFOLLOW; refusing an unanchored write', + ); + } + return; } + if (process.platform === 'darwin') { + if (!directoryFlagsAvailable) { + throw new Error( + 'Safe generated-plan writes require macOS O_DIRECTORY/O_NOFOLLOW; refusing an unverified write', + ); + } + return; + } + throw new Error( + `Safe generated-plan writes require Linux /proc/self/fd or macOS O_DIRECTORY/O_NOFOLLOW; ${process.platform} offers neither, so refusing an unanchored write`, + ); } function descriptorPath(fd, childName) { @@ -900,157 +1023,352 @@ function descriptorPath(fd, childName) { return childName === undefined ? base : path.join(base, childName); } -function externalDescriptorPath(fd, childName) { - const base = `/proc/${process.pid}/fd/${fd}`; - return childName === undefined ? base : path.join(base, childName); +// Directory opens are plain O_RDONLY|O_DIRECTORY|O_NOFOLLOW|O_CLOEXEC on both +// platforms, and deliberately nothing else. +// +// O_NOFOLLOW_ANY (macOS 11+) used to be ORed in here on the theory that XNU +// ignores unrecognized open flag bits, so it would be inert where unsupported. +// That was wrong: combined with O_DIRECTORY macOS rejects it outright with +// EINVAL, and every directory open on Darwin failed. It is gone and is not +// coming back behind a probe or a degrade-on-EINVAL path — the per-component +// O_NOFOLLOW walk is what delivers the guarantee. Rust's cap-std, the closest +// reference implementation of this problem, has not adopted O_NOFOLLOW_ANY +// either (their issue #179 is still open). +function openVerifiedDirectory(absolute, flags) { + return fs.openSync(absolute, flags); } -const RENAME_NOREPLACE_SCRIPT = String.raw` -import ctypes -import errno -import os -import sys - -libc = ctypes.CDLL(None, use_errno=True) -try: - renameat2 = libc.renameat2 -except AttributeError: - print("libc does not expose renameat2", file=sys.stderr) - raise SystemExit(125) - -renameat2.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint] -renameat2.restype = ctypes.c_int -result = renameat2(-100, os.fsencode(sys.argv[1]), -100, os.fsencode(sys.argv[2]), 1) -if result != 0: - error_number = ctypes.get_errno() - error_name = errno.errorcode.get(error_number, "UNKNOWN") - print(f"renameat2 RENAME_NOREPLACE failed: {error_name}: {os.strerror(error_number)}", file=sys.stderr) - raise SystemExit(17 if error_number == errno.EEXIST else 126) -`; - -let atomicMoverPath; - -function spawnHeldExecutable(executable, args, options) { - const before = fs.fstatSync(executable.fd, { bigint: true }); - if (!before.isFile() || statIdentity(before) !== executable.identity) { - throw new Error('Validated Python executable changed before invocation'); - } - const result = spawnSync('/proc/self/fd/3', args, { - ...options, - stdio: ['ignore', 'pipe', 'pipe', executable.fd], - }); - const after = fs.fstatSync(executable.fd, { bigint: true }); - assertStableIdentity(before, after, 'validated Python executable'); - return result; +// File opens additionally get O_NONBLOCK, which directory opens do not need: +// it stops a FIFO swapped in at the target name from wedging the process on +// open. The identity comparison that follows rejects the FIFO anyway, but only +// if we ever get as far as running it. +function openVerifiedFile(absolute, flags, mode) { + const nonBlocking = flags | (fs.constants.O_NONBLOCK ?? 0); + return mode === undefined + ? fs.openSync(absolute, nonBlocking) + : fs.openSync(absolute, nonBlocking, mode); } -function validatedPathExecutable(candidate) { - if (!path.isAbsolute(candidate)) return null; - const candidateDirectory = path.dirname(candidate); - let resolvedDirectory; - let resolved; - let directoryStats; - let executableStat; +// The publish primitive, identical on both platforms. +// +// link() is the portable no-replace publish: it fails with EEXIST if the +// destination name is taken — by a regular file, by a directory, or by a symlink, +// live or dangling — and it never follows that symlink to clobber its target. +// It also works where renameat2(RENAME_NOREPLACE) does not, notably v9fs, which +// is why the WSL2 9p case that used to fail every time now works. +// +// The published file is the same inode as the temporary, so every identity +// comparison the callers already make still holds, and validateCommittedPlan +// becomes strictly stronger: it compares the destination against the exact inode +// whose bytes were fsynced. +// +// On Linux both paths are /proc/self/fd//, so the publish is anchored +// to the held parent descriptors exactly like every other operation. +// link(2) BUGS: "On NFS filesystems, the return code may be wrong in case the NFS +// server performs the link creation and dies before it can say so. Use stat(2) to +// find out if the link got created." open(2) NOTES gives the remedy this +// implements: on a reported failure, stat the source and see whether its link +// count reached 2. A false positive would need someone to have hardlinked a +// 16-random-byte name inside a directory we hold open — and validateCommittedPlan +// still proves the destination is the exact temporary inode afterwards. +function linkCreatedDespiteError(sourcePath) { try { - resolvedDirectory = fs.realpathSync(candidateDirectory); - resolved = fs.realpathSync(candidate); - const resolvedExecutableDirectory = fs.realpathSync(path.dirname(resolved)); - directoryStats = [...new Set([resolvedDirectory, resolvedExecutableDirectory])].map( - (directory) => fs.statSync(directory), - ); - executableStat = fs.lstatSync(resolved); - fs.accessSync(resolved, fs.constants.X_OK); + return fs.statSync(sourcePath, { bigint: true }).nlink === 2n; } catch { - return null; + return false; } - if ( - directoryStats.some((stat) => !stat.isDirectory()) || - !executableStat.isFile() || - executableStat.isSymbolicLink() - ) { - return null; - } - const uid = typeof process.getuid === 'function' ? process.getuid() : null; - const trustedOwner = (stat) => uid === null || stat.uid === 0 || stat.uid === uid; - if ( - directoryStats.some((stat) => !trustedOwner(stat) || (stat.mode & 0o022) !== 0) || - !trustedOwner(executableStat) || - (executableStat.mode & 0o022) !== 0 - ) { - return null; - } - return resolved; } -function resolveAtomicMover() { - if (atomicMoverPath) return atomicMoverPath; - const candidates = new Set(); - for (const entry of (process.env.PATH ?? '').split(path.delimiter)) { - if (entry && path.isAbsolute(entry)) candidates.add(path.join(entry, 'python3')); - } - for (const entry of ['/usr/local/bin/python3', '/usr/bin/python3', '/bin/python3']) { - candidates.add(entry); - } - for (const candidate of candidates) { - const resolved = validatedPathExecutable(candidate); - if (!resolved) continue; - let fd; - try { - fd = fs.openSync( - resolved, - fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_CLOEXEC ?? 0), - ); - } catch { - continue; +function linkNoReplace(sourcePath, destinationPath) { + try { + fs.linkSync(sourcePath, destinationPath); + } catch (error) { + // Callers treat "destination taken" as a distinct outcome, not a failure. + if (error?.code === 'EEXIST') return false; + if (!linkCreatedDespiteError(sourcePath)) { + // FAT, Coda, and some SMB/FUSE/virtiofs mounts have no hardlinks at all. + // Git falls back to rename here, but git can afford to lose collision + // detection because its objects are content-addressed; a plan destination + // is a plain name, so a replacing rename would silently clobber whatever + // is already there. Refuse loudly instead. + if (error?.code === 'EPERM' || error?.code === 'ENOTSUP' || error?.code === 'EMLINK') { + throw new Error( + `Generated-plan publication requires hard links, which this filesystem refused (${error.code}); refusing to fall back to a replacing rename`, + ); + } + throw error; } - const opened = fs.fstatSync(fd, { bigint: true }); - const executable = { fd, identity: statIdentity(opened), resolved }; - const version = spawnHeldExecutable( - executable, - ['-I', '-S', '-c', 'import sys; print(sys.version_info[0])'], - { - encoding: 'utf8', - env: { ...process.env, LANG: 'C', LC_ALL: 'C' }, - timeout: 10_000, - windowsHide: true, - }, - ); - if (version.status === 0 && version.stdout.trim() === '3') { - atomicMoverPath = executable; - return executable; - } - fs.closeSync(fd); } - throw new Error( - 'Safe generated-plan publication requires a trusted absolute Python 3 PATH candidate with libc renameat2 support', - ); -} - -function atomicMoveNoReplace(source, destination) { - const mover = resolveAtomicMover(); - const result = spawnHeldExecutable( - mover, - ['-I', '-S', '-c', RENAME_NOREPLACE_SCRIPT, source, destination], - { - encoding: 'utf8', - env: { ...process.env, LANG: 'C', LC_ALL: 'C' }, - timeout: 10_000, - windowsHide: true, - }, - ); - if (result.error) throw result.error; - if (result.status === 17) return false; - if (result.status !== 0) { - throw new Error( - `Atomic no-replace move failed (${result.status}): ${(result.stderr ?? '').trim()}`, - ); + try { + fs.unlinkSync(sourcePath); + } catch { + // The link succeeded, so the plan IS published. A temporary name left behind + // is a stray file, not an unpublished plan: reporting it as a failure would + // be a lie, and rolling back would unpublish a plan that is already live. } return true; } -function lstatOptional(absolute) { +// A directory holder is anything that owns a verified chain: a plan-parent +// handle, a ref's parent directory, or an absence guard. Two arrays describe it, +// both root-first and the same length — `chain` records each element's expected +// path and dev/ino/mode, and `descriptors` holds an open descriptor on each. +// +// Holding those descriptors is load-bearing rather than decorative. dev/ino/mode +// is unique only among *live* inodes: an inode number freed by an rmdir is handed +// straight back to the next mkdir, so a replacement directory can reproduce a +// recorded identity exactly. An open descriptor pins the inode, so the number +// cannot be recycled for as long as the holder exists. +function verifyPinnedDescriptors(holder) { + const { chain, descriptors } = holder; + if (!Array.isArray(descriptors) || descriptors.length !== chain.length) { + throw new Error('Generated-plan parent chain is missing the descriptors that pin it'); + } + chain.forEach((item, index) => { + const pinned = fs.fstatSync(descriptors[index], { bigint: true }); + if (!pinned.isDirectory() || stableDirectoryIdentity(pinned) !== item.identity) { + throw new Error('Generated-plan parent descriptor changed during the write'); + } + }); +} + +function verifyLexicalChain(holder) { + for (const item of holder.chain) { + let lexical; + try { + lexical = fs.lstatSync(item.expectedPath, { bigint: true }); + } catch (error) { + if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') throw error; + // A parent renamed out from under us is a mismatch, not a missing file: + // reporting the raw ENOENT would leak an unrelated-looking error out of a + // check whose whole job is to say the chain no longer holds. + throw new Error('Generated-plan lexical parent no longer matches its directory descriptor'); + } + if ( + lexical.isSymbolicLink() || + !lexical.isDirectory() || + stableDirectoryIdentity(lexical) !== item.identity + ) { + throw new Error('Generated-plan lexical parent no longer matches its directory descriptor'); + } + } +} + +// The whole platform seam, in five methods. Everything else an operation does is +// identical on both platforms and lives in the shared functions below. +// +// Only two things actually differ: how a name becomes a path, and what guard +// wraps the operation that uses it. +// +// Linux ANCHORS. /proc/self/fd// starts the walk at the inode the +// descriptor holds, so a parent renamed away cannot be traversed at all and the +// guard is a no-op — there is nothing left to verify. +// +// macOS VERIFIES. It resolves lexically, so before and after every operation it +// proves that each element of the path chain still names the exact inode being +// held for it. That DETECTS a swapped parent and aborts; it does not make the +// swap impossible. A swap landing inside the window is caught by the trailing +// check, after the fact, rather than being unreachable. The check runs after a +// failure too, because a verdict observed through a chain that has since changed +// is not a verdict. +const LINUX_ANCHORING = { + childPath(dirHandle, childName) { + return descriptorPath(dirHandle.fd, childName); + }, + verified(holders, run) { + return run(); + }, + descriptorMatchesChild(fd, expectedPath) { + return fs.realpathSync.native(descriptorPath(fd)) === expectedPath; + }, + parentStillResolves(parentHandle) { + return fs.realpathSync.native(descriptorPath(parentHandle.fd)) === parentHandle.expectedPath; + }, + verifyAbsentChild(guard) { + if (absentChildIsPresent(guard.ref)) { + throw new Error(`${guard.repoPath} appeared before evidence materialization completed`); + } + }, +}; + +const DARWIN_ANCHORING = { + childPath(dirHandle, childName) { + return path.join(dirHandle.expectedPath, childName); + }, + verified(holders, run) { + const list = Array.isArray(holders) ? holders : [holders]; + const proveChain = () => { + for (const holder of list) { + verifyPinnedDescriptors(holder); + verifyLexicalChain(holder); + } + }; + proveChain(); + let value; + try { + value = run(); + } catch (error) { + proveChain(); + throw error; + } + proveChain(); + return value; + }, + descriptorMatchesChild(fd, _expectedPath, childStat) { + // There is no live fd-to-path oracle on macOS (F_GETPATH is a name-cache + // snapshot, not an anchor), so escape is decided the other way round: the + // name was just resolved under a verified chain, and the descriptor opened + // from it counts only if it is that same inode. + const opened = fs.fstatSync(fd, { bigint: true }); + return ( + opened.isDirectory() && stableDirectoryIdentity(opened) === stableDirectoryIdentity(childStat) + ); + }, + parentStillResolves(parentHandle) { + // Both halves are needed: a directory renamed away keeps its inode, so the + // descriptors alone still match and only the lexical half notices it moved. + try { + verifyPinnedDescriptors(parentHandle); + verifyLexicalChain(parentHandle); + } catch { + return false; + } + return true; + }, + verifyAbsentChild(guard) { + let present; + try { + present = DARWIN_ANCHORING.verified(guard.handle, () => absentChildIsPresent(guard.ref)); + } catch (error) { + // A chain that no longer holds makes the absence verdict meaningless, and + // the caller reports that as the anchor changing rather than as a stray + // parent-descriptor error. Linux cannot reach this: its guard is a no-op. + throw new Error( + `Absence anchor changed for ${guard.repoPath}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + if (present) { + throw new Error(`${guard.repoPath} appeared before evidence materialization completed`); + } + }, +}; + +const ANCHORING_BACKENDS = new Map([ + ['linux', LINUX_ANCHORING], + ['darwin', DARWIN_ANCHORING], +]); + +function anchoringBackend() { + const backend = ANCHORING_BACKENDS.get(process.platform); + if (!backend) { + // requireDescriptorAnchoring normally refuses first; this is the same answer + // from the other side, so an unsupported platform can never fall through to + // whichever backend happened to be the ternary's default. + throw new Error( + `No generated-plan anchoring backend for ${process.platform}; refusing an unanchored write`, + ); + } + return backend; +} + +// Open, fstat, compare, close on mismatch. The descriptor never escapes this +// function unless it refers to the inode the caller already verified by name, so +// a lexical open that landed anywhere else cannot be used by accident. On Linux +// the comparison passes trivially — the /proc walk already resolved from the +// held parent — and costs one fstat to keep the guarantee structural rather than +// dependent on which backend is in play. +function adoptVerifiedFile(ref, expectedStat, flags) { + const fd = openVerifiedFile(ref.path, flags); + let opened; try { - return fs.lstatSync(absolute, { bigint: true }); + opened = fs.fstatSync(fd, { bigint: true }); + } catch (error) { + fs.closeSync(fd); + throw error; + } + if (stableFileIdentity(opened) !== stableFileIdentity(expectedStat)) { + fs.closeSync(fd); + return null; + } + return fd; +} + +function absentChildIsPresent(ref) { + try { + fs.lstatSync(ref.path, { bigint: true }); + } catch (error) { + if (error?.code === 'ENOENT') return false; + throw error; + } + return true; +} + +// The operations. Each is the same on both platforms; only the guard differs. +function lstatChild(ref) { + return anchoringBackend().verified(ref.dir, () => fs.lstatSync(ref.path, { bigint: true })); +} + +function openChildRead(ref, flags, expectedStat) { + return anchoringBackend().verified(ref.dir, () => { + const fd = adoptVerifiedFile(ref, expectedStat, flags); + if (fd === null) { + throw new Error(`${ref.name} was replaced between its verified stat and its no-follow open`); + } + return fd; + }); +} + +function createChild(ref, flags, mode) { + // O_CREAT|O_EXCL|O_NOFOLLOW is atomic at the leaf, so the only thing the guard + // has to cover is which directory the leaf landed in. + return anchoringBackend().verified(ref.dir, () => openVerifiedFile(ref.path, flags, mode)); +} + +function mkdirChild(ref, mode) { + anchoringBackend().verified(ref.dir, () => fs.mkdirSync(ref.path, { mode })); +} + +function publishNoReplace(sourceRef, destinationRef) { + return anchoringBackend().verified([sourceRef.dir, destinationRef.dir], () => + linkNoReplace(sourceRef.path, destinationRef.path), + ); +} + +// The single place a name becomes a path, and therefore the right place to +// enforce that a name is one ordinary component. +// +// A trailing separator is the sharp edge here, not a tidiness concern: +// open(path, O_NOFOLLOW) FOLLOWS a symlink when path ends in "/" — the trap +// behind CVE-2026-39822 / golang/go#79005, which let os.Root escape its own +// root. path.join preserves that trailing slash, so a component carrying one +// would turn every no-follow open in this file into a following one. +// normalizeRepoPath already rejects such components upstream; this is the +// chokepoint that makes it true for every caller, including the generated +// temporary and vault names that never pass through it. +function anchoredChild(dirHandle, childName) { + if ( + typeof childName !== 'string' || + childName === '' || + childName === '.' || + childName === '..' || + childName.includes('/') || + childName.includes('\\') || + childName.includes('\0') + ) { + throw new Error(`Refusing to resolve ${JSON.stringify(childName)} as a single path component`); + } + return { + dir: dirHandle, + name: childName, + path: anchoringBackend().childPath(dirHandle, childName), + }; +} + +function lstatAnchoredOptional(ref) { + try { + return lstatChild(ref); } catch (error) { if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return null; throw error; @@ -1063,39 +1381,37 @@ function openPlanParent( { createMissing = true, purpose = 'Generated-plan' } = {}, ) { requireDescriptorAnchoring(); - const flags = - fs.constants.O_RDONLY | - fs.constants.O_DIRECTORY | - fs.constants.O_NOFOLLOW | - (fs.constants.O_CLOEXEC ?? 0); + // Root-first and index-aligned with `chain`: verifyPinnedDescriptors relies on + // that, and the descriptors are what pin each recorded inode against reuse. const descriptors = []; try { - let currentFd = fs.openSync(repo, flags); + let currentFd = openVerifiedDirectory(repo, ANCHORED_DIRECTORY_FLAGS); descriptors.push(currentFd); const rootStat = fs.fstatSync(currentFd, { bigint: true }); const chain = [{ expectedPath: repo, identity: stableDirectoryIdentity(rootStat) }]; + let currentHandle = { fd: currentFd, expectedPath: repo, chain, descriptors }; const traversed = []; for (const component of parentComponents) { traversed.push(component); - const anchoredChild = descriptorPath(currentFd, component); + const child = anchoredChild(currentHandle, component); let childStat; let created = false; try { - childStat = fs.lstatSync(anchoredChild, { bigint: true }); + childStat = lstatChild(child); } catch (error) { if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') throw error; if (!createMissing) { throw new Error(`${purpose} parent does not exist: ${traversed.join('/')}`); } - fs.mkdirSync(anchoredChild, { mode: 0o755 }); - childStat = fs.lstatSync(anchoredChild, { bigint: true }); + mkdirChild(child, 0o755); + childStat = lstatChild(child); created = true; } if (childStat.isSymbolicLink() || !childStat.isDirectory()) { throw new Error(`${purpose} parent is not a real directory: ${traversed.join('/')}`); } const parentFd = currentFd; - const childFd = fs.openSync(anchoredChild, flags); + const childFd = openVerifiedDirectory(child.path, ANCHORED_DIRECTORY_FLAGS); descriptors.push(childFd); currentFd = childFd; if (created) { @@ -1103,18 +1419,16 @@ function openPlanParent( fs.fsyncSync(parentFd); } const expected = path.join(repo, ...traversed); - const actual = fs.realpathSync(descriptorPath(currentFd)); - if (actual !== expected) { + if (!anchoringBackend().descriptorMatchesChild(currentFd, expected, childStat)) { throw new Error(`${purpose} parent escaped the repository: ${traversed.join('/')}`); } const openedStat = fs.fstatSync(currentFd, { bigint: true }); chain.push({ expectedPath: expected, identity: stableDirectoryIdentity(openedStat) }); + currentHandle = { fd: currentFd, expectedPath: expected, chain, descriptors }; } - const stat = fs.fstatSync(currentFd, { bigint: true }); return { descriptors, fd: currentFd, - identity: stableDirectoryIdentity(stat), expectedPath: path.join(repo, ...parentComponents), chain, }; @@ -1134,9 +1448,16 @@ function closeDescriptors(descriptors) { } } +// A handle's identity IS its chain leaf's identity. Storing it twice meant two +// fstats a line apart and a re-stamp helper to keep them agreeing; deriving it +// removes both. +function handleIdentity(handle) { + return handle.chain[handle.chain.length - 1].identity; +} + function resolveGitDirectory(repo) { const result = git(repo, ['rev-parse', '--absolute-git-dir']); - return fs.realpathSync(decodeUtf8(result.stdout, 'Git administrative directory').trim()); + return fs.realpathSync.native(decodeUtf8(result.stdout, 'Git administrative directory').trim()); } function openBackupVault(repo, { createMissing = true } = {}) { @@ -1147,9 +1468,12 @@ function openBackupVault(repo, { createMissing = true } = {}) { }); fs.fchmodSync(handle.fd, 0o700); fs.fsyncSync(handle.fd); - const stat = fs.fstatSync(handle.fd, { bigint: true }); - handle.identity = stableDirectoryIdentity(stat); - handle.chain[handle.chain.length - 1].identity = handle.identity; + // mode is part of every directory identity, so hardening the vault changes the + // identity the chain recorded for it; without this the next verification would + // reject the directory it just hardened. + handle.chain[handle.chain.length - 1].identity = stableDirectoryIdentity( + fs.fstatSync(handle.fd, { bigint: true }), + ); return { ...handle, gitDirectory }; } @@ -1157,33 +1481,28 @@ function validatePlanParent(parentHandle) { const descriptorStat = fs.fstatSync(parentHandle.fd, { bigint: true }); if ( !descriptorStat.isDirectory() || - stableDirectoryIdentity(descriptorStat) !== parentHandle.identity + stableDirectoryIdentity(descriptorStat) !== handleIdentity(parentHandle) ) { throw new Error('Generated-plan parent descriptor changed during the write'); } - const descriptorRealPath = fs.realpathSync(descriptorPath(parentHandle.fd)); - if (descriptorRealPath !== parentHandle.expectedPath) { + if (!anchoringBackend().parentStillResolves(parentHandle)) { throw new Error('Generated-plan parent moved or was replaced during the write'); } - for (const item of parentHandle.chain) { - const lexicalStat = fs.lstatSync(item.expectedPath, { bigint: true }); - if ( - lexicalStat.isSymbolicLink() || - !lexicalStat.isDirectory() || - stableDirectoryIdentity(lexicalStat) !== item.identity - ) { - throw new Error('Generated-plan lexical parent no longer matches its directory descriptor'); - } - } + // Both halves come from the shared helpers rather than being restated here: an + // earlier hand-copy of the lexical loop lost verifyLexicalChain's ENOENT/ENOTDIR + // translation, so a renamed parent could surface a raw errno from a function + // with a dozen call sites. + verifyPinnedDescriptors(parentHandle); + verifyLexicalChain(parentHandle); } function inspectPlanDestination( - finalPath, + finalRef, { replace, expectedIdentity, mustBeAbsent = false } = {}, ) { let stat; try { - stat = fs.lstatSync(finalPath, { bigint: true }); + stat = lstatChild(finalRef); } catch (error) { if (error?.code === 'ENOENT') { if (expectedIdentity) throw new Error('Generated plan disappeared during the write'); @@ -1201,19 +1520,17 @@ function inspectPlanDestination( if (expectedIdentity && identity !== expectedIdentity) { throw new Error('Generated plan changed during the write'); } - return identity; + return stat; } -function openExistingPlanDestination(finalPath, replace) { - const identity = inspectPlanDestination(finalPath, { replace }); - if (identity === null) { +function openExistingPlanDestination(finalRef, replace) { + const stat = inspectPlanDestination(finalRef, { replace }); + if (stat === null) { if (replace) throw new Error('Deepen mode requires an existing generated plan to replace'); return { fd: undefined, identity: null, stableIdentity: null }; } - const fd = fs.openSync( - finalPath, - fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_CLOEXEC ?? 0), - ); + const identity = statIdentity(stat); + const fd = openChildRead(finalRef, VERIFIED_READ_FLAGS, stat); try { const opened = fs.fstatSync(fd, { bigint: true }); if (!opened.isFile() || statIdentity(opened) !== identity) { @@ -1264,8 +1581,8 @@ function hashOpenFile(fd, label) { }; } -function validateCommittedPlan(finalPath, tempFd, expectedTemp, testHooks) { - const before = fs.lstatSync(finalPath, { bigint: true }); +function validateCommittedPlan(finalRef, tempFd, expectedTemp, testHooks) { + const before = lstatChild(finalRef); if ( before.isSymbolicLink() || !before.isFile() || @@ -1273,19 +1590,16 @@ function validateCommittedPlan(finalPath, tempFd, expectedTemp, testHooks) { ) { throw new Error('Generated-plan destination failed its first post-write identity check'); } - const finalFd = fs.openSync( - finalPath, - fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_CLOEXEC ?? 0), - ); + const finalFd = openChildRead(finalRef, VERIFIED_READ_FLAGS, before); try { const opened = fs.fstatSync(finalFd, { bigint: true }); if (!opened.isFile() || stableFileIdentity(opened) !== expectedTemp.identity) { throw new Error('Generated-plan destination changed while its no-follow descriptor opened'); } - testHooks?.afterFinalOpen?.({ fd: finalFd, finalPath }); + testHooks?.afterFinalOpen?.({ fd: finalFd, finalPath: finalRef.path }); const committedViaTemp = hashOpenFile(tempFd, 'generated-plan committed file'); const committedViaPath = hashOpenFile(finalFd, 'generated-plan destination descriptor'); - const after = fs.lstatSync(finalPath, { bigint: true }); + const after = lstatChild(finalRef); const openedAfter = fs.fstatSync(finalFd, { bigint: true }); if ( after.isSymbolicLink() || @@ -1320,22 +1634,19 @@ function copyOpenFile(sourceFd, destinationFd, label) { return after; } -function openVerifiedPathFile(absolute, label) { - const before = fs.lstatSync(absolute, { bigint: true }); +function openVerifiedAnchoredFile(ref, label, knownStat) { + const before = knownStat ?? lstatChild(ref); if (before.isSymbolicLink() || !before.isFile()) { throw new Error(`${label} is not a regular no-follow file`); } - const fd = fs.openSync( - absolute, - fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_CLOEXEC ?? 0), - ); + const fd = openChildRead(ref, VERIFIED_READ_FLAGS, before); try { const opened = fs.fstatSync(fd, { bigint: true }); if (!opened.isFile() || stableFileIdentity(opened) !== stableFileIdentity(before)) { throw new Error(`${label} changed while its descriptor opened`); } const layer = hashOpenFile(fd, label); - const after = fs.lstatSync(absolute, { bigint: true }); + const after = lstatChild(ref); if (after.isSymbolicLink() || !after.isFile() || stableFileIdentity(after) !== layer.identity) { throw new Error(`${label} changed after verification`); } @@ -1358,10 +1669,10 @@ export function readPlanSafely({ repo: repoInput, generatedPlanPath, testHooks } let fd; try { validatePlanParent(parentHandle); - const finalPath = descriptorPath(parentHandle.fd, finalName); + const finalRef = anchoredChild(parentHandle, finalName); let before; try { - before = fs.lstatSync(finalPath, { bigint: true }); + before = lstatChild(finalRef); } catch (error) { if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') { throw new Error(`Loaded plan does not exist: ${generatedPlan}`); @@ -1371,15 +1682,12 @@ export function readPlanSafely({ repo: repoInput, generatedPlanPath, testHooks } if (before.isSymbolicLink() || !before.isFile()) { throw new Error('Loaded plan must be a regular file, never a symlink'); } - fd = fs.openSync( - finalPath, - fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_CLOEXEC ?? 0), - ); + fd = openChildRead(finalRef, VERIFIED_READ_FLAGS, before); const opened = fs.fstatSync(fd, { bigint: true }); if (!opened.isFile() || statIdentity(opened) !== statIdentity(before)) { throw new Error('Loaded plan changed while its no-follow descriptor opened'); } - testHooks?.afterPlanOpen?.({ fd, finalPath }); + testHooks?.afterPlanOpen?.({ fd, finalPath: finalRef.path }); const chunks = []; let total = 0; const buffer = Buffer.allocUnsafe(64 * 1024); @@ -1394,7 +1702,7 @@ export function readPlanSafely({ repo: repoInput, generatedPlanPath, testHooks } decodeUtf8(contents, 'loaded plan'); const after = fs.fstatSync(fd, { bigint: true }); assertStableIdentity(opened, after, 'loaded plan'); - const pathAfter = fs.lstatSync(finalPath, { bigint: true }); + const pathAfter = lstatChild(finalRef); if ( pathAfter.isSymbolicLink() || !pathAfter.isFile() || @@ -1419,24 +1727,22 @@ function artifactGitPath(name) { return `gitnexus-plan-backups/${name}`; } -function verifyVaultArtifactFromFreshRoot(repo, gitPath, expectedLayer) { - const components = gitPath.split('/'); - if (components.length !== 2 || components[0] !== 'gitnexus-plan-backups') { - throw new Error(`Invalid Git-admin artifact path: ${gitPath}`); - } +function verifyVaultArtifactFromFreshRoot(repo, name, expectedLayer) { const freshVault = openBackupVault(repo, { createMissing: false }); try { validatePlanParent(freshVault); - const opened = openVerifiedPathFile( - descriptorPath(freshVault.fd, components[1]), - `Git-admin artifact ${gitPath}`, + const opened = openVerifiedAnchoredFile( + anchoredChild(freshVault, name), + `Git-admin artifact ${artifactGitPath(name)}`, ); try { if ( opened.layer.identity !== expectedLayer.identity || opened.layer.digest !== expectedLayer.digest ) { - throw new Error(`Git-admin artifact changed before fresh-root verification: ${gitPath}`); + throw new Error( + `Git-admin artifact changed before fresh-root verification: ${artifactGitPath(name)}`, + ); } } finally { fs.closeSync(opened.fd); @@ -1449,16 +1755,8 @@ function verifyVaultArtifactFromFreshRoot(repo, gitPath, expectedLayer) { function createVaultCopyFromFd(repo, vault, sourceFd, role) { validatePlanParent(vault); const name = `.gitnexus-plan-${role}-${process.pid}-${randomBytes(16).toString('hex')}.bak`; - const absolute = descriptorPath(vault.fd, name); - const destinationFd = fs.openSync( - absolute, - fs.constants.O_RDWR | - fs.constants.O_CREAT | - fs.constants.O_EXCL | - fs.constants.O_NOFOLLOW | - (fs.constants.O_CLOEXEC ?? 0), - 0o600, - ); + const artifact = anchoredChild(vault, name); + const destinationFd = createChild(artifact, VERIFIED_CREATE_FLAGS, 0o600); let destination; try { const sourceStat = copyOpenFile(sourceFd, destinationFd, role); @@ -1469,7 +1767,7 @@ function createVaultCopyFromFd(repo, vault, sourceFd, role) { if (source.size !== destination.size || source.digest !== destination.digest) { throw new Error(`${role} vault copy does not match its held source descriptor`); } - const pathStat = fs.lstatSync(absolute, { bigint: true }); + const pathStat = lstatChild(artifact); if ( pathStat.isSymbolicLink() || !pathStat.isFile() || @@ -1481,24 +1779,15 @@ function createVaultCopyFromFd(repo, vault, sourceFd, role) { } finally { fs.closeSync(destinationFd); } - const gitPath = artifactGitPath(name); - verifyVaultArtifactFromFreshRoot(repo, gitPath, destination); - return { role, gitPath, layer: destination }; + verifyVaultArtifactFromFreshRoot(repo, name, destination); + return { role, gitPath: artifactGitPath(name), layer: destination }; } function createVaultCopyFromBytes(repo, vault, contents, role) { validatePlanParent(vault); const name = `.gitnexus-plan-${role}-${process.pid}-${randomBytes(16).toString('hex')}.bak`; - const absolute = descriptorPath(vault.fd, name); - const fd = fs.openSync( - absolute, - fs.constants.O_RDWR | - fs.constants.O_CREAT | - fs.constants.O_EXCL | - fs.constants.O_NOFOLLOW | - (fs.constants.O_CLOEXEC ?? 0), - 0o600, - ); + const artifact = anchoredChild(vault, name); + const fd = createChild(artifact, VERIFIED_CREATE_FLAGS, 0o600); let layer; try { writeAll(fd, contents); @@ -1508,7 +1797,7 @@ function createVaultCopyFromBytes(repo, vault, contents, role) { if (layer.size !== BigInt(contents.length) || layer.digest !== sha256(contents)) { throw new Error(`${role} vault copy does not match the intended plan bytes`); } - const pathStat = fs.lstatSync(absolute, { bigint: true }); + const pathStat = lstatChild(artifact); if ( pathStat.isSymbolicLink() || !pathStat.isFile() || @@ -1520,32 +1809,31 @@ function createVaultCopyFromBytes(repo, vault, contents, role) { } finally { fs.closeSync(fd); } - const gitPath = artifactGitPath(name); - verifyVaultArtifactFromFreshRoot(repo, gitPath, layer); - return { role, gitPath, layer }; + verifyVaultArtifactFromFreshRoot(repo, name, layer); + return { role, gitPath: artifactGitPath(name), layer }; } function movePathToVault(repo, sourceHandle, sourceName, vault, role) { - const source = descriptorPath(sourceHandle.fd, sourceName); - if (!lstatOptional(source)) return null; + const source = anchoredChild(sourceHandle, sourceName); + if (!lstatAnchoredOptional(source)) return null; const name = `.gitnexus-plan-${role}-${process.pid}-${randomBytes(16).toString('hex')}.bak`; - const destination = descriptorPath(vault.fd, name); - const moved = atomicMoveNoReplace( - externalDescriptorPath(sourceHandle.fd, sourceName), - externalDescriptorPath(vault.fd, name), - ); + const destination = anchoredChild(vault, name); + const moved = publishNoReplace(source, destination); if (!moved) throw new Error(`${role} preservation destination unexpectedly exists`); fs.fsyncSync(sourceHandle.fd); if (vault.fd !== sourceHandle.fd) fs.fsyncSync(vault.fd); - const sourceAfter = lstatOptional(source); - const destinationAfter = lstatOptional(destination); + const sourceAfter = lstatAnchoredOptional(source); + const destinationAfter = lstatAnchoredOptional(destination); if (sourceAfter || !destinationAfter) { throw new Error(`${role} could not be atomically moved into the Git-admin vault`); } - const opened = openVerifiedPathFile(destination, `${role} Git-admin artifact`); - const gitPath = artifactGitPath(name); - verifyVaultArtifactFromFreshRoot(repo, gitPath, opened.layer); - return { role, gitPath, layer: opened.layer, fd: opened.fd }; + const opened = openVerifiedAnchoredFile( + destination, + `${role} Git-admin artifact`, + destinationAfter, + ); + verifyVaultArtifactFromFreshRoot(repo, name, opened.layer); + return { role, gitPath: artifactGitPath(name), layer: opened.layer, fd: opened.fd }; } function formatPreservedArtifacts(artifacts) { @@ -1600,10 +1888,10 @@ export function writePlanSafely({ const finalName = components.pop(); let parentHandle; let vaultHandle; - let tempPath; + let tempRef; let tempName; let tempFd; - let finalPath; + let finalRef; let expectedTemp; let originalDestination; let priorBackup; @@ -1611,7 +1899,6 @@ export function writePlanSafely({ try { parentHandle = openPlanParent(repo, components); vaultHandle = openBackupVault(repo); - resolveAtomicMover(); const parentDevice = fs.fstatSync(parentHandle.fd, { bigint: true }).dev; const vaultDevice = fs.fstatSync(vaultHandle.fd, { bigint: true }).dev; if (parentDevice !== vaultDevice) { @@ -1622,19 +1909,11 @@ export function writePlanSafely({ testHooks?.afterParentOpen?.({ fd: parentHandle.fd, path: parentHandle.expectedPath }); validatePlanParent(parentHandle); validatePlanParent(vaultHandle); - finalPath = descriptorPath(parentHandle.fd, finalName); - originalDestination = openExistingPlanDestination(finalPath, shouldReplace); + finalRef = anchoredChild(parentHandle, finalName); + originalDestination = openExistingPlanDestination(finalRef, shouldReplace); tempName = `.gitnexus-plan-${process.pid}-${randomBytes(16).toString('hex')}.tmp`; - tempPath = descriptorPath(parentHandle.fd, tempName); - tempFd = fs.openSync( - tempPath, - fs.constants.O_RDWR | - fs.constants.O_CREAT | - fs.constants.O_EXCL | - fs.constants.O_NOFOLLOW | - (fs.constants.O_CLOEXEC ?? 0), - 0o600, - ); + tempRef = anchoredChild(parentHandle, tempName); + tempFd = createChild(tempRef, VERIFIED_CREATE_FLAGS, 0o600); writeAll(tempFd, contents); fs.fchmodSync(tempFd, 0o644); fs.fsyncSync(tempFd); @@ -1646,12 +1925,12 @@ export function writePlanSafely({ testHooks?.beforeRename?.({ fd: parentHandle.fd, path: parentHandle.expectedPath, - tempPath, + tempPath: tempRef.path, }); validatePlanParent(parentHandle); validatePlanParent(vaultHandle); validateOpenPlanDestination(originalDestination); - const tempPathStat = fs.lstatSync(tempPath, { bigint: true }); + const tempPathStat = lstatChild(tempRef); const currentTemp = hashOpenFile(tempFd, 'generated-plan temporary file'); if ( tempPathStat.isSymbolicLink() || @@ -1664,7 +1943,7 @@ export function writePlanSafely({ } if (shouldReplace) { - testHooks?.beforeBackupMove?.({ fd: parentHandle.fd, finalPath }); + testHooks?.beforeBackupMove?.({ fd: parentHandle.fd, finalPath: finalRef.path }); const originalLayer = hashOpenFile(originalDestination.fd, 'prior generated plan'); if (originalLayer.digest !== expectedDigest) { throw new Error( @@ -1673,7 +1952,7 @@ export function writePlanSafely({ } validatePlanParent(parentHandle); validateOpenPlanDestination(originalDestination); - inspectPlanDestination(finalPath, { + inspectPlanDestination(finalRef, { replace: true, expectedIdentity: originalDestination.identity, }); @@ -1691,20 +1970,20 @@ export function writePlanSafely({ ); throw new Error('Destination raced while the prior plan was moved into preservation'); } - if (lstatOptional(finalPath)) { + if (lstatAnchoredOptional(finalRef)) { throw new Error('Destination reappeared after the prior plan was preserved'); } } testHooks?.beforePublication?.({ fd: parentHandle.fd, - finalPath, - tempPath, + finalPath: finalRef.path, + tempPath: tempRef.path, replace: shouldReplace, }); validatePlanParent(parentHandle); validatePlanParent(vaultHandle); - const finalTempPathStat = fs.lstatSync(tempPath, { bigint: true }); + const finalTempPathStat = lstatChild(tempRef); const finalTemp = hashOpenFile(tempFd, 'generated-plan temporary file'); if ( finalTempPathStat.isSymbolicLink() || @@ -1715,19 +1994,25 @@ export function writePlanSafely({ ) { throw new Error('Generated-plan temporary path or content changed at publication'); } - atomicMoveNoReplace( - externalDescriptorPath(parentHandle.fd, tempName), - externalDescriptorPath(parentHandle.fd, finalName), - ); - if (lstatOptional(tempPath) || !lstatOptional(finalPath)) { + // link() reports the race itself; re-deriving that verdict from a later pair + // of stats would be both slower and weaker. + if (!publishNoReplace(tempRef, finalRef)) { throw new Error('Generated-plan publication was refused because the destination raced'); } + // link() creates a directory entry, so it needs the parent fsync that rename + // needed: the file's own bytes were fsynced through tempFd before this point, + // and this makes the name that now reaches them durable too. Skipping it is + // the step write-file-atomic omits and maildir, git and atomicwrites all + // mandate. + // + // Honest limitation: on macOS fsync is not a write barrier — the durable + // primitive there is fcntl(F_FULLFSYNC), which Node does not expose. A + // macOS plan write is therefore as durable as fsync makes it and no more. fs.fsyncSync(parentHandle.fd); - testHooks?.afterPublication?.({ fd: parentHandle.fd, finalPath }); - testHooks?.afterRename?.({ fd: parentHandle.fd, finalPath }); + testHooks?.afterPublication?.({ fd: parentHandle.fd, finalPath: finalRef.path }); validatePlanParent(parentHandle); validatePlanParent(vaultHandle); - validateCommittedPlan(finalPath, tempFd, expectedTemp, testHooks); + validateCommittedPlan(finalRef, tempFd, expectedTemp, testHooks); const receipt = { generated_plan_path: generatedPlan, bytes_written: contents.length }; if (priorBackup) receipt.prior_plan_backup_git_path = priorBackup.gitPath; return receipt; @@ -1848,6 +2133,11 @@ export function snapshotEvidence({ const headGuards = captureHeadGuards(repo); const dirty = initialDirty.records; const mutationGuards = []; + // Per-snapshot walk state: `absenceCache` owns every descriptor an absence + // anchor holds, deduplicated by repo-relative prefix and closed exactly once + // below; `guardedDirectories` keeps parent guarding to one stat per directory. + const absenceCache = new Map(); + const walkState = { absenceCache, guardedDirectories: new Set() }; try { testHooks?.afterAnchorCapture?.({ headCommit: head }); @@ -1862,7 +2152,9 @@ export function snapshotEvidence({ testHooks?.afterGitLayerLoad?.({ headCommit: head }); const globalEntries = [...dirty.values()] .filter((record) => record.path !== generatedPlan) - .map((record) => materializeRecord(repo, record, layers, mutationGuards, testHooks)); + .map((record) => + materializeRecord(repo, record, layers, mutationGuards, testHooks, walkState), + ); const citedEntries = [...normalizedCitations].sort(compareUtf8).map((repoPath) => { const status = dirty.get(repoPath) ?? { path: repoPath, @@ -1871,7 +2163,7 @@ export function snapshotEvidence({ rename_to: null, has_untracked: false, }; - const entry = materializeRecord(repo, status, layers, mutationGuards, testHooks); + const entry = materializeRecord(repo, status, layers, mutationGuards, testHooks, walkState); const present = Object.values(entry.object_kind).some((kind) => kind !== ABSENT); if (!present) entry.state = ABSENT; else if (entry.state === 'clean' && entry.object_kind.untracked !== ABSENT) { @@ -1906,21 +2198,13 @@ export function snapshotEvidence({ throw new Error(`${guard.absolute} changed before evidence materialization completed`); } } else if (guard.type === 'absence') { + // statIdentity is a strict superset of stableDirectoryIdentity on the + // same stat, so comparing both could only ever fire together. const parent = fs.fstatSync(guard.fd, { bigint: true }); - if ( - !parent.isDirectory() || - stableDirectoryIdentity(parent) !== guard.parentIdentity || - statIdentity(parent) !== guard.parentMutationIdentity - ) { + if (!parent.isDirectory() || statIdentity(parent) !== guard.parentMutationIdentity) { throw new Error(`Absence anchor changed for ${guard.repoPath}`); } - try { - fs.lstatSync(descriptorPath(guard.fd, guard.childName), { bigint: true }); - } catch (error) { - if (error?.code === 'ENOENT') continue; - throw error; - } - throw new Error(`${guard.repoPath} appeared before evidence materialization completed`); + anchoringBackend().verifyAbsentChild(guard); } } for (const guard of headGuards) verifyControlFile(guard); @@ -1955,12 +2239,10 @@ export function snapshotEvidence({ cited_path_manifest: citedEntries, }; } finally { - const closed = new Set(); - for (const guard of mutationGuards) { - if (guard.type !== 'absence' || closed.has(guard.fd)) continue; - closed.add(guard.fd); + // One entry per distinct anchored directory, so one close per descriptor. + for (const handle of absenceCache.values()) { try { - fs.closeSync(guard.fd); + fs.closeSync(handle.fd); } catch { // Preserve the primary snapshot result/error. } diff --git a/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs b/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs index e68aca1de..564384f83 100644 --- a/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs +++ b/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs @@ -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 }; diff --git a/gitnexus-cursor-integration/skills/gitnexus-debugging/SKILL.md b/gitnexus-cursor-integration/skills/gitnexus-debugging/SKILL.md index 6f8944fd4..41fb568f8 100644 --- a/gitnexus-cursor-integration/skills/gitnexus-debugging/SKILL.md +++ b/gitnexus-cursor-integration/skills/gitnexus-debugging/SKILL.md @@ -1,20 +1,40 @@ --- name: gitnexus-debugging -description: Trace bugs through call chains using knowledge graph +description: "Use when the user is debugging a bug, tracing an error, or asking why something fails. Examples: \"Why is X failing?\", \"Where does this error come from?\", \"Trace this bug\"" --- # Debugging with GitNexus ## When to Use + - "Why is this function failing?" - "Trace where this error comes from" - "Who calls this method?" - "This endpoint returns 500" - Investigating bugs, errors, or unexpected behavior +## Bind the repository first + +A root cause traced in the wrong repository is a wrong root cause. + +Call `list_repos {}` before the first tool call. With one indexed repository, +use the examples below as written. With more than one, pass `repo` on every +call: an omitted `repo` normally errors, but under an MCP policy with a +configured default it resolves to that default silently. If you cannot tell +which repository is meant, stop and ask. This matters most for `cypher`, whose +statement carries no in-band hint of which database it ran against. + +`list_repos` is paginated, so page with `offset: pagination.nextOffset` until +`hasMore` is false before concluding a repository is absent. + +A stale index describes the code from before your bug, so refresh before +trusting a trace, and state the repository and index freshness with the +diagnosis. + ## Workflow ``` +0. list_repos {} → Bind repo 1. query({search_query: ""}) → Find related execution flows 2. context({name: ""}) → See callers/callees/processes 3. READ gitnexus://repo/{name}/process/{name} → Trace execution flow @@ -26,6 +46,7 @@ description: Trace bugs through call chains using knowledge graph ## Checklist ``` +- [ ] list_repos {} — bind repo; explicit repo when >1 indexed, ask if ambiguous - [ ] Understand the symptom (error message, unexpected behavior) - [ ] query for error text or related code - [ ] Identify the suspect function from returned processes @@ -33,45 +54,52 @@ description: Trace bugs through call chains using knowledge graph - [ ] Trace execution flow via process resource if applicable - [ ] cypher for custom call chain traces if needed - [ ] Read source files to confirm root cause +- [ ] State the repository and index freshness with the diagnosis ``` ## Debugging Patterns -| Symptom | GitNexus Approach | -|---------|-------------------| -| Error message | `query` for error text → `context` on throw sites | -| Wrong return value | `context` on the function → trace callees for data flow | -| Intermittent failure | `context` → look for external calls, async deps | -| Performance issue | `context` → find symbols with many callers (hot paths) | -| Recent regression | `detect_changes` to see what your changes affect | +| Symptom | GitNexus Approach | +| -------------------- | ---------------------------------------------------------- | +| Error message | `query` for error text → `context` on throw sites | +| Wrong return value | `context` on the function → trace callees for data flow | +| Intermittent failure | `context` → look for external calls, async deps | +| Performance issue | `context` → find symbols with many callers (hot paths) | +| Recent regression | `detect_changes` to see what your changes affect — pass `worktree` for a linked worktree | | "How does A reach B?" | `trace` between the two symbols — shortest call chain in one call | ## Tools **query** — find code related to error: + ``` -query({search_query: "payment validation error"}) +query({search_query: "payment validation error", repo: "my-app"}) → Processes: CheckoutFlow, ErrorHandling → Symbols: validatePayment, handlePaymentError, PaymentException ``` **context** — full context for a suspect: + ``` -context({name: "validatePayment"}) +context({name: "validatePayment", repo: "my-app"}) → Incoming calls: processCheckout, webhookHandler → Outgoing calls: verifyCard, fetchRates (external API!) → Processes: CheckoutFlow (step 3/7) ``` -**cypher** — custom call chain traces: +**cypher** — custom call chain traces. Pass `repo` alongside the statement; the +Cypher text itself names no repository, so the result is unattributable without +it: + ```cypher MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Function {name: "validatePayment"}) RETURN [n IN nodes(path) | n.name] AS chain ``` **trace** — shortest call chain between two symbols ("how does A reach B?"), one call instead of chaining `context` hops: + ``` -trace({ from: "processCheckout", to: "fetchRates" }) +trace({ from: "processCheckout", to: "fetchRates", repo: "my-app" }) → status: ok, hopCount: 3 → hops: processCheckout → validatePayment → verifyCard → fetchRates → edges: CALLS (1.0), CALLS (0.95), CALLS (1.0) @@ -82,15 +110,22 @@ When no path exists, `trace` reports the furthest reachable node — exactly whe ## Example: "Payment endpoint returns 500 intermittently" ``` -1. query({search_query: "payment error handling"}) +0. list_repos {} + → total: 2 (my-app, billing-api) — bind my-app explicitly on every call + +1. query({search_query: "payment error handling", repo: "my-app"}) → Processes: CheckoutFlow, ErrorHandling → Symbols: validatePayment, handlePaymentError -2. context({name: "validatePayment"}) +2. context({name: "validatePayment", repo: "my-app"}) → Outgoing calls: verifyCard, fetchRates (external API!) 3. READ gitnexus://repo/my-app/process/CheckoutFlow → Step 3: validatePayment → calls fetchRates (external) 4. Root cause: fetchRates calls external API without proper timeout + Repository: my-app Index: current ``` + +With a single indexed repository, step 0 returns `total: 1` and the `repo` +argument drops out of every call above. diff --git a/gitnexus-cursor-integration/skills/gitnexus-exploring/SKILL.md b/gitnexus-cursor-integration/skills/gitnexus-exploring/SKILL.md index 993a38481..46fc187ce 100644 --- a/gitnexus-cursor-integration/skills/gitnexus-exploring/SKILL.md +++ b/gitnexus-cursor-integration/skills/gitnexus-exploring/SKILL.md @@ -1,21 +1,34 @@ --- name: gitnexus-exploring -description: Navigate unfamiliar code using GitNexus knowledge graph +description: "Use when the user asks how code works, wants to understand architecture, trace execution flows, or explore unfamiliar parts of the codebase. Examples: \"How does X work?\", \"What calls this function?\", \"Show me the auth flow\"" --- # Exploring Codebases with GitNexus ## When to Use + - "How does authentication work?" - "What's the project structure?" - "Show me the main components" - "Where is the database logic?" - Understanding code you haven't seen before +## Bind the repository first + +Step 1 discovers what is indexed; every call after it must say which of those +it means. With one indexed repository, use the examples below as written. With +more than one, pass `repo` on every call: an omitted `repo` normally errors, +but under an MCP policy with a configured default it resolves to that default +silently. If you cannot tell which repository is meant, stop and ask. Report +the bound repository and index freshness alongside your explanation. + +`list_repos` is paginated, so page with `offset: pagination.nextOffset` until +`hasMore` is false before concluding a repository is absent. + ## Workflow ``` -1. READ gitnexus://repos → Discover indexed repos +1. list_repos {} or READ gitnexus://repos → Discover indexed repos 2. READ gitnexus://repo/{name}/context → Codebase overview, check staleness 3. query({search_query: ""}) → Find related execution flows 4. context({name: ""}) → Deep dive on specific symbol @@ -27,44 +40,52 @@ description: Navigate unfamiliar code using GitNexus knowledge graph ## Checklist ``` +- [ ] list_repos {} — bind repo; explicit repo when >1 indexed, ask if ambiguous - [ ] READ gitnexus://repo/{name}/context - [ ] query for the concept you want to understand - [ ] Review returned processes (execution flows) - [ ] context on key symbols for callers/callees - [ ] READ process resource for full execution traces - [ ] Read source files for implementation details +- [ ] State the repository and index freshness with the explanation ``` ## Resources -| Resource | What you get | -|----------|-------------| -| `gitnexus://repo/{name}/context` | Stats, staleness warning (~150 tokens) | -| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores (~300 tokens) | -| `gitnexus://repo/{name}/cluster/{name}` | Area members with file paths (~500 tokens) | -| `gitnexus://repo/{name}/process/{name}` | Step-by-step execution trace (~200 tokens) | +| Resource | What you get | +| --------------------------------------- | ------------------------------------------------------- | +| `gitnexus://repo/{name}/context` | Stats, staleness warning (~150 tokens) | +| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores (~300 tokens) | +| `gitnexus://repo/{name}/cluster/{name}` | Area members with file paths (~500 tokens) | +| `gitnexus://repo/{name}/process/{name}` | Step-by-step execution trace (~200 tokens) | ## Tools **query** — find execution flows related to a concept: + ``` -query({search_query: "payment processing"}) +query({search_query: "payment processing", repo: "my-app"}) → Processes: CheckoutFlow, RefundFlow, WebhookHandler → Symbols grouped by flow with file locations ``` **context** — 360-degree view of a symbol: + ``` -context({name: "validateUser"}) +context({name: "validateUser", repo: "my-app"}) → Incoming calls: loginHandler, apiMiddleware → Outgoing calls: checkToken, getUserById → Processes: LoginFlow (step 2/5), TokenRefresh (step 1/3) ``` +`repo` is required once more than one repository is indexed, and may be omitted +with a single one. + ## Example: "How does payment processing work?" ``` -1. READ gitnexus://repo/my-app/context → 918 symbols, 45 processes +1. list_repos {} → total: 1 (my-app) — bind it + READ gitnexus://repo/my-app/context → 918 symbols, 45 processes 2. query({search_query: "payment processing"}) → CheckoutFlow: processPayment → validateCard → chargeStripe → RefundFlow: initiateRefund → calculateRefund → processRefund @@ -72,4 +93,8 @@ context({name: "validateUser"}) → Incoming: checkoutHandler, webhookHandler → Outgoing: validateCard, chargeStripe, saveTransaction 4. Read src/payments/processor.ts for implementation details +5. Answer, noting: Repository my-app, index current ``` + +Had step 1 returned two repositories, every call above would carry +`repo: "my-app"`. diff --git a/gitnexus-cursor-integration/skills/gitnexus-impact-analysis/SKILL.md b/gitnexus-cursor-integration/skills/gitnexus-impact-analysis/SKILL.md index 7a3586b29..85d90c90d 100644 --- a/gitnexus-cursor-integration/skills/gitnexus-impact-analysis/SKILL.md +++ b/gitnexus-cursor-integration/skills/gitnexus-impact-analysis/SKILL.md @@ -1,11 +1,12 @@ --- name: gitnexus-impact-analysis -description: Analyze blast radius before making code changes +description: "Use when the user wants to know what will break if they change something, or needs safety analysis before editing code. Examples: \"Is it safe to change X?\", \"What depends on this?\", \"What will break?\"" --- # Impact Analysis with GitNexus ## When to Use + - "Is it safe to change this function?" - "What will break if I modify X?" - "Show me the blast radius" @@ -13,13 +14,42 @@ description: Analyze blast radius before making code changes - Before making non-trivial code changes - Before committing — to understand what your changes affect +## Bind the repository first + +Impact analysis is the gate that authorizes an edit, so it must answer for the +repository you are about to edit. + +Call `list_repos {}` before the first tool call. With one indexed repository, +use the examples below as written. With more than one, pass `repo` on every +call: an omitted `repo` normally errors, but under an MCP policy with a +configured default it resolves to that default silently. If you cannot tell +which repository is meant, stop and ask — every result below an ambiguous +identity inherits the ambiguity. `list_repos` is paginated, so page with +`offset: pagination.nextOffset` until `hasMore` is false before concluding a +repository is absent. + +`detect_changes` takes `worktree` when your changes are in a linked worktree +the MCP server was not launched from. The server auto-detects a worktree only +when it was launched from inside one; otherwise `git diff` runs in the wrong +checkout and reports zero changed symbols — a false clean check that carries +none of the degradation flags described below. In the CLI fallbacks, `--repo .` +means the current checkout; pass the intended repository path instead when you +are not standing in it. + +State the bound identity with your risk report: + +``` +Repository: () Worktree: Index: , behind HEAD +``` + ## Workflow ``` +0. list_repos {} → Bind repo (and worktree) 1. impact({target: "X", direction: "upstream"}) or `node .gitnexus/run.cjs impact "X" --direction upstream --repo .` 2. READ gitnexus://repo/{name}/processes → Check affected execution flows 3. detect_changes({scope: "all"}) or `node .gitnexus/run.cjs detect-changes --scope all --repo .` -4. Assess risk and report to user +4. Assess risk and report to user, echoing repo/worktree/index identity ``` > If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal. @@ -28,29 +58,31 @@ description: Analyze blast radius before making code changes ## Checklist ``` +- [ ] list_repos {} — bind repo; explicit repo when >1 indexed, ask if ambiguous - [ ] impact({target, direction: "upstream"}) or CLI fallback to find dependents - [ ] Review d=1 items first (these WILL BREAK) - [ ] Check high-confidence (>0.8) dependencies - [ ] READ processes to check affected execution flows - [ ] detect_changes({scope: "all"}) or CLI fallback for pre-commit check -- [ ] Assess risk level and report to user +- [ ] Confirm the checkout you edited is the checkout that was diffed +- [ ] Assess risk level and report, stating repo/worktree/index identity ``` ## Understanding Output -| Depth | Risk Level | Meaning | -|-------|-----------|---------| -| d=1 | **WILL BREAK** | Direct callers/importers | -| d=2 | LIKELY AFFECTED | Indirect dependencies | -| d=3 | MAY NEED TESTING | Transitive effects | +| Depth | Risk Level | Meaning | +| ----- | ---------------- | ------------------------ | +| d=1 | **WILL BREAK** | Direct callers/importers | +| d=2 | LIKELY AFFECTED | Indirect dependencies | +| d=3 | MAY NEED TESTING | Transitive effects | ## Risk Assessment -| Affected | Risk | -|----------|------| -| <5 symbols, few processes | LOW | -| 5-15 symbols, 2-5 processes | MEDIUM | -| >15 symbols or many processes | HIGH | +| Affected | Risk | +| ------------------------------ | -------- | +| <5 symbols, few processes | LOW | +| 5-15 symbols, 2-5 processes | MEDIUM | +| >15 symbols or many processes | HIGH | | Critical path (auth, payments) | CRITICAL | | **Zero callers found** | **UNKNOWN** | @@ -61,12 +93,23 @@ 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 --direction upstream --repo .` instead: + ``` impact({ target: "validateUser", + repo: "my-app", // required once >1 repository is indexed direction: "upstream", minConfidence: 0.8, maxDepth: 3 @@ -81,6 +124,7 @@ impact({ ``` **detect_changes** — git-diff based impact analysis. If MCP is unavailable, use `node .gitnexus/run.cjs detect-changes --scope all --repo .` instead: + ``` detect_changes({scope: "all"}) @@ -89,10 +133,26 @@ detect_changes({scope: "all"}) → Risk: MEDIUM ``` +Add `repo` once more than one repository is indexed, and `worktree: ""` when your changes are in a linked worktree the server was not launched +from. + +`partial: true` (a graph query failed) or `truncated: true` (the changed-symbol +listing was capped) means the result is short of the truth, and reads like +`UNKNOWN` above: a zero there means unseen, not unaffected. Re-run it rather +than tick the pre-commit check. + +A wrong-worktree zero carries neither flag and is shape-identical to a genuine +clean result, so confirm the checkout you edited is the one that was diffed +before treating an empty change set as a passed check. + ## Example: "What breaks if I change validateUser?" ``` -1. impact({target: "validateUser", direction: "upstream"}) or `node .gitnexus/run.cjs impact "validateUser" --direction upstream --repo .` +0. list_repos {} + → total: 2 (my-app, billing-api) — both define validateUser, so bind explicitly + +1. impact({target: "validateUser", repo: "my-app", direction: "upstream"}) or `node .gitnexus/run.cjs impact "validateUser" --direction upstream --repo .` → d=1: loginHandler, apiMiddleware (WILL BREAK) → d=2: authRouter, sessionManager (LIKELY AFFECTED) @@ -100,4 +160,8 @@ detect_changes({scope: "all"}) → LoginFlow and TokenRefresh touch validateUser 3. Risk: 2 direct callers, 2 processes = MEDIUM + Repository: my-app (/abs/path/my-app) Worktree: same Index: current ``` + +With a single indexed repository, step 0 returns `total: 1` and the `repo` +argument drops out of every call above. diff --git a/gitnexus-cursor-integration/skills/gitnexus-refactoring/SKILL.md b/gitnexus-cursor-integration/skills/gitnexus-refactoring/SKILL.md index 9495a19d5..9d63eb6e3 100644 --- a/gitnexus-cursor-integration/skills/gitnexus-refactoring/SKILL.md +++ b/gitnexus-cursor-integration/skills/gitnexus-refactoring/SKILL.md @@ -1,20 +1,44 @@ --- name: gitnexus-refactoring -description: Plan safe refactors using blast radius and dependency mapping +description: "Use when the user wants to rename, extract, split, move, or restructure code safely. Examples: \"Rename this function\", \"Extract this into a module\", \"Refactor this class\", \"Move this to a separate file\"" --- # Refactoring with GitNexus ## When to Use + - "Rename this function safely" - "Extract this into a module" - "Split this service" - "Move this to a new file" - Any task involving renaming, extracting, splitting, or restructuring code +## Bind the repository first + +Refactoring writes to disk. `rename` with `dry_run: false` edits files in +whichever repository was resolved, so binding identity here is a safety gate, +not bookkeeping. + +Call `list_repos {}` before the first tool call. With one indexed repository, +use the examples below as written. With more than one, pass `repo` on every +call: an omitted `repo` normally errors, but under an MCP policy with a +configured default it resolves to that default silently. If you cannot tell +which repository is meant, stop and ask. Never run `rename` with +`dry_run: false` until the preview in the same bound repository has been +reviewed — its returned `file_path` values show which checkout is about to be +written, so read them as a confirmation of identity. + +`list_repos` is paginated, so page with `offset: pagination.nextOffset` until +`hasMore` is false before concluding a repository is absent. + +`detect_changes` takes `worktree` when you are editing a linked worktree the +MCP server was not launched from; otherwise `git diff` runs in the wrong +checkout and reports nothing changed, which reads as a verified refactor. + ## Workflow ``` +0. list_repos {} → Bind repo (and worktree) 1. impact({target: "X", direction: "upstream"}) → Map all dependents 2. query({search_query: "X"}) → Find execution flows involving X 3. context({name: "X"}) → See all incoming/outgoing refs @@ -26,8 +50,11 @@ description: Plan safe refactors using blast radius and dependency mapping ## Checklists ### Rename Symbol + ``` +- [ ] list_repos {} — bind repo; explicit repo when >1 indexed, ask if ambiguous - [ ] rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits +- [ ] Confirm the previewed file paths are in the bound repository/worktree - [ ] Review graph edits (high confidence) and text_search edits (review carefully) - [ ] If satisfied: rename({..., dry_run: false}) — apply edits - [ ] detect_changes() — verify only expected files changed @@ -35,7 +62,9 @@ description: Plan safe refactors using blast radius and dependency mapping ``` ### Extract Module + ``` +- [ ] list_repos {} — bind repo; explicit repo when >1 indexed, ask if ambiguous - [ ] context({name: target}) — see all incoming/outgoing refs - [ ] impact({target, direction: "upstream"}) — find all external callers - [ ] Define new module interface @@ -45,7 +74,9 @@ description: Plan safe refactors using blast radius and dependency mapping ``` ### Split Function/Service + ``` +- [ ] list_repos {} — bind repo; explicit repo when >1 indexed, ask if ambiguous - [ ] context({name: target}) — understand all callees - [ ] Group callees by responsibility - [ ] impact({target, direction: "upstream"}) — map callers to update @@ -58,21 +89,24 @@ description: Plan safe refactors using blast radius and dependency mapping ## Tools **rename** — automated multi-file rename: + ``` -rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true}) +rename({symbol_name: "validateUser", new_name: "authenticateUser", repo: "my-app", dry_run: true}) → 12 edits across 8 files → 10 graph edits (high confidence), 2 text_search edits (review) → Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}] ``` **impact** — map all dependents first: + ``` -impact({target: "validateUser", direction: "upstream"}) +impact({target: "validateUser", repo: "my-app", direction: "upstream"}) → d=1: loginHandler, apiMiddleware, testUtils → Affected Processes: LoginFlow, TokenRefresh ``` **detect_changes** — verify your changes after refactoring: + ``` detect_changes({scope: "all"}) → Changed: 8 files, 12 symbols @@ -80,7 +114,16 @@ detect_changes({scope: "all"}) → Risk: MEDIUM ``` +`partial: true` (a graph query failed) or `truncated: true` (the changed-symbol +listing was capped) means the result is short of the truth: a short or empty +list is not proof that only the expected files changed. Re-run it rather than +treat the refactor as verified. + +A wrong-worktree zero carries neither flag and is indistinguishable from a +clean verification, so confirm the diffed checkout is the one you edited. + **cypher** — custom reference queries: + ```cypher MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "validateUser"}) RETURN caller.name, caller.filePath ORDER BY caller.filePath @@ -88,26 +131,34 @@ RETURN caller.name, caller.filePath ORDER BY caller.filePath ## Risk Rules -| Risk Factor | Mitigation | -|-------------|------------| -| Many callers (>5) | Use rename for automated updates | -| Cross-area refs | Use detect_changes after to verify scope | -| String/dynamic refs | query to find them | -| External/public API | Version and deprecate properly | +| Risk Factor | Mitigation | +| ------------------- | ----------------------------------------- | +| Many callers (>5) | Use rename for automated updates | +| Cross-area refs | Use detect_changes after to verify scope | +| String/dynamic refs | query to find them | +| External/public API | Version and deprecate properly | +| Same name in another indexed repo | Bind `repo`; verify previewed paths before applying | ## Example: Rename `validateUser` to `authenticateUser` ``` -1. rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true}) +0. list_repos {} + → total: 2 (my-app, billing-api) — both define validateUser, so bind explicitly + +1. rename({symbol_name: "validateUser", new_name: "authenticateUser", repo: "my-app", dry_run: true}) → 12 edits: 10 graph (safe), 2 text_search (review) → Files: validator.ts, login.ts, middleware.ts, config.json... 2. Review text_search edits (config.json: dynamic reference!) -3. rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false}) +3. rename({symbol_name: "validateUser", new_name: "authenticateUser", repo: "my-app", dry_run: false}) → Applied 12 edits across 8 files -4. detect_changes({scope: "all"}) +4. detect_changes({scope: "all", repo: "my-app"}) → Affected: LoginFlow, TokenRefresh → Risk: MEDIUM — run tests for these flows + Repository: my-app (/abs/path/my-app) Worktree: same Index: current ``` + +With a single indexed repository, step 0 returns `total: 1` and the `repo` +argument drops out of every call above. diff --git a/gitnexus-shared/package-lock.json b/gitnexus-shared/package-lock.json index 0fee05147..4359ce75c 100644 --- a/gitnexus-shared/package-lock.json +++ b/gitnexus-shared/package-lock.json @@ -8,21 +8,382 @@ "name": "gitnexus-shared", "version": "1.0.0", "devDependencies": { - "typescript": "^6.0.3" + "typescript": "^7.0.2" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" } }, "node_modules/typescript": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", "dev": true, "license": "Apache-2.0", "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" + "tsc": "bin/tsc" }, "engines": { - "node": ">=14.17" + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" } } } diff --git a/gitnexus-shared/package.json b/gitnexus-shared/package.json index 0a5d7a2db..a60f54ccb 100644 --- a/gitnexus-shared/package.json +++ b/gitnexus-shared/package.json @@ -24,6 +24,6 @@ "src" ], "devDependencies": { - "typescript": "^6.0.3" + "typescript": "^7.0.2" } } diff --git a/gitnexus-shared/src/graph/types.ts b/gitnexus-shared/src/graph/types.ts index d9d916e0d..8b46113df 100644 --- a/gitnexus-shared/src/graph/types.ts +++ b/gitnexus-shared/src/graph/types.ts @@ -45,6 +45,21 @@ export type NodeLabel = | 'Section' | 'Route' | 'Tool' + /** + * A message-broker destination — a Kafka topic, a Rabbit exchange/routing + * key, a JMS queue, a Spring Cloud Stream binding. The framework overlay for + * ASYNCHRONOUS entry/exit points, symmetric to `Route` for HTTP. + * + * Identity is `(broker, resolved ADDRESS)`, so a publisher and a consumer of + * the same address on the same broker land on one node and the connection is + * a single hop — while a Kafka topic and a Rabbit queue that share a name + * stay two nodes, the same way `GET /x` and `POST /x` are two Routes. A + * destination whose address could NOT be resolved is keyed by its source + * location instead and carries no `address` property at all. See + * `pipeline-phases/spring-destinations.ts` for why an unresolved spelling may + * not key a node, and `ingestion/destination-key.ts` for why the broker may. + */ + | 'Destination' // Taint/PDG substrate (issue #2080). Intra-procedural control-flow node. // Emitted by no phase yet — M1 (#2081) populates these behind an opt-in. | 'BasicBlock'; @@ -95,6 +110,30 @@ export type NodeProperties = { responseKeys?: string[]; errorKeys?: string[]; middleware?: string[]; + /** Route runtime evidence is authoritative only when this is exactly true. */ + runtimeConfirmed?: boolean; + /** Provenance of runtime evidence; presence alone does not imply confirmation. */ + runtimeSource?: string; + /** Runtime result such as runtime-confirmed or handler-conflict. */ + runtimeStatus?: string; + // Destination (async messaging overlay). See the `Destination` label above. + /** The RESOLVED broker address. Together with `broker` it is the key a + * cross-repository pass joins on. Present only when the address resolved: + * absent is the load-bearing state, because an absent property cannot match + * another absent property. */ + address?: string; + /** Broker family the syntax attests to (`kafka`, `rabbit`, `jms`, …). Part + * of the node's identity alongside `address`, not a label on it. */ + broker?: string; + /** How the address was arrived at (`literal`, `constant`) when it resolved, + * or the named reason it did not. */ + resolution?: string; + /** Configuration key named by an unresolvable `${…}` placeholder. The key + * only — configuration VALUES are deliberately absent from this graph. */ + configKey?: string; + /** The `${key:default}` default text. Not an address: configuration can + * override it and the graph cannot see whether it did. */ + configDefault?: string; // BasicBlock (taint/PDG substrate, issue #2080) — reuses filePath/startLine/endLine. text?: string; /** BasicBlock: space-joined leaf callee names invoked in the block — the @@ -122,6 +161,19 @@ export type RelationshipType = | 'MEMBER_OF' | 'STEP_IN_PROCESS' | 'HANDLES_ROUTE' + /** Outbound async messaging. Source = the callable that performs the publish + * (or its File); target = the `Destination` it publishes to. Emitted by + * `pipeline-phases/spring-destinations.ts` from Spring messaging-template + * calls (`kafkaTemplate.send(...)`, `rabbitTemplate.convertAndSend(...)`). + * One edge per address: a publish that names two destinations yields two + * edges, and `reason` records which argument each came from. */ + | 'PUBLISHES_TO' + /** Inbound async messaging — the mirror of `PUBLISHES_TO`. Source = the + * annotated handler callable (or its File); target = the `Destination` it + * subscribes to. Emitted from `@KafkaListener` / `@RabbitListener` / + * `@JmsListener` and their siblings. Together the two types make + * "who else reads what this service writes" a two-hop traversal. */ + | 'CONSUMES_FROM' | 'FETCHES' | 'HANDLES_TOOL' | 'ENTRY_POINT_OF' diff --git a/gitnexus-shared/src/impact-risk.ts b/gitnexus-shared/src/impact-risk.ts new file mode 100644 index 000000000..413d02f76 --- /dev/null +++ b/gitnexus-shared/src/impact-risk.ts @@ -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 = 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 = 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, + }, + }; +} diff --git a/gitnexus-shared/src/index.ts b/gitnexus-shared/src/index.ts index 13c2eac5a..9857a60cc 100644 --- a/gitnexus-shared/src/index.ts +++ b/gitnexus-shared/src/index.ts @@ -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'; diff --git a/gitnexus-shared/src/lbug/schema-constants.ts b/gitnexus-shared/src/lbug/schema-constants.ts index 350aa273d..217c382a6 100644 --- a/gitnexus-shared/src/lbug/schema-constants.ts +++ b/gitnexus-shared/src/lbug/schema-constants.ts @@ -40,6 +40,8 @@ export const NODE_TABLES = [ 'Module', 'Route', 'Tool', + // Async messaging overlay — the broker-side counterpart of `Route`. + 'Destination', // Taint/PDG substrate (issue #2080) — inert until M1 (#2081) emits blocks. 'BasicBlock', ] as const; @@ -64,6 +66,8 @@ export const REL_TYPES = [ 'MEMBER_OF', 'STEP_IN_PROCESS', 'HANDLES_ROUTE', + 'PUBLISHES_TO', + 'CONSUMES_FROM', 'FETCHES', 'HANDLES_TOOL', 'ENTRY_POINT_OF', diff --git a/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts index e50337af3..e2a90c253 100644 --- a/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts +++ b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts @@ -373,6 +373,8 @@ function makeEdgeDrafts( targetFile: null, targetExportedName: extractExportedName(parsed), kind: edgeKindFor(parsed), + ...typeOnlyFor(parsed), + ...runsOnlyWhenCalledFor(parsed), linkStatus: 'unresolved', }; return [ @@ -392,7 +394,13 @@ function makeEdgeDrafts( // and resolved-dynamic imports are terminal at the file level — no // `targetDefId` needed since they materialize no `BindingRef`. Pre- // finalize them here so the fixpoint loop skips them entirely. - const targetFiles = Array.isArray(targetFile) ? targetFile : [targetFile]; + // Annotated rather than inferred: `isArray`'s `arg is any[]` predicate widens + // the true branch to a MUTABLE array, and a resolver may hand back a cached, + // frozen candidate list (Kotlin's `dirChildren` buckets do). Only `.map` is + // wanted here, so pinning `readonly` makes an in-place `.sort()`/`.push()` — + // which would reorder that resolver's index for the rest of the run — a + // compile error rather than a runtime TypeError. + const targetFiles: readonly string[] = Array.isArray(targetFile) ? targetFile : [targetFile]; const isFileLevelTerminal = parsed.kind === 'side-effect' || parsed.kind === 'dynamic-resolved'; return targetFiles.map((tf) => { const base: ImportEdge = { @@ -403,6 +411,8 @@ function makeEdgeDrafts( hooks.isNamespaceImport?.(parsed, tf, file.filePath) === true ? 'namespace' : edgeKindFor(parsed), + ...typeOnlyFor(parsed), + ...runsOnlyWhenCalledFor(parsed), }; return { source: parsed, @@ -420,6 +430,73 @@ function edgeKindFor(parsed: ParsedImport): ImportEdge['kind'] { return parsed.kind; } +/** + * Carry `ParsedImport.typeOnly` onto the edge — the erasure fact `check + * --cycles` needs and cannot re-derive, because `kind` is identical for the + * erased and the runtime spelling of the same import (`import type D` and + * `import D` both arrive as `alias`). + * + * `'typeOnly' in parsed` rather than a switch over the erasable kinds: only + * four variants declare the property, so `parsed.typeOnly` does not compile + * against the whole union, and `in` narrows it without naming them. That is + * also the safer shape — an enumeration has to be updated when a variant gains + * the property or the fact silently stops reaching the edge, while this form + * handles a new variant correctly whether or not it declares one. + * + * Returns a spreadable object rather than a `boolean` so an edge that is not + * type-only keeps the exact property set it had before this field existed. + * Every `finalized` edge is built by spreading `base`, so setting it here is + * enough for all of them. + */ +function typeOnlyFor(parsed: ParsedImport): { typeOnly?: true } { + return 'typeOnly' in parsed && parsed.typeOnly === true ? { typeOnly: true } : {}; +} + +/** + * Re-carry both runtime-presence flags from an existing edge onto a derived + * one. + * + * `expandWildcard` builds each `wildcard-expanded` edge from scratch rather + * than spreading the source (three fields differ per exported name), so every + * field it does not name is dropped. That is exactly how both flags were lost + * once already. Naming the pair here keeps "these two travel together" in one + * place, so a third presence flag is added in one place too. + */ +function carriedPresenceFlags(edge: Pick): { + typeOnly?: true; + runsOnlyWhenCalled?: true; +} { + return { + ...(edge.typeOnly === true ? { typeOnly: true } : {}), + ...(edge.runsOnlyWhenCalled === true ? { runsOnlyWhenCalled: true } : {}), + }; +} + +/** + * Carry `ParsedImport.runsOnlyWhenCalled` onto the edge — the position fact + * `check --cycles` needs and, unlike every other property of an import, cannot + * look up for itself. + * + * The scope an import was written in does not survive to here: + * `FinalizeFile.parsedImports` is a flat per-file list, and Phase 4 publishes + * the finalized edges under `file.moduleScope` (see `linkedByScope.set` above), + * so the consumer's map is keyed by the Module scope for every file. Walking + * that map's key to look for an enclosing `Function` therefore always starts — + * and ends — at a `Module`. Only the extractor still knows, so the edge has to + * carry what it decided. + * + * No `in` guard, unlike {@link typeOnlyFor}: position is a property of where + * the statement sits, so every variant declares `runsOnlyWhenCalled` and + * `parsed.runsOnlyWhenCalled` compiles against the whole union. A new variant + * that omits it is a build break here, which is the right outcome. + * + * Returns a spreadable object rather than a `boolean` so an edge that is not + * deferred keeps the exact property set it had before this field existed. + */ +function runsOnlyWhenCalledFor(parsed: ParsedImport): { runsOnlyWhenCalled?: true } { + return parsed.runsOnlyWhenCalled === true ? { runsOnlyWhenCalled: true } : {}; +} + function extractLocalName(parsed: ParsedImport): string { switch (parsed.kind) { case 'wildcard': @@ -515,9 +592,11 @@ function tryFinalize( return null; } - const viaFiles = [targetFile, ...followed.via]; + // Capped here too, not just inside the closure: this is the last hop, the + // one the emitted edge carries. + const viaFiles = extendVia(targetFile, followed.via); const transitiveVia = - draft.source.kind === 'reexport' || viaFiles.length > 1 ? Object.freeze(viaFiles) : undefined; + draft.source.kind === 'reexport' || viaFiles.length > 1 ? viaFiles : undefined; return { ...draft.base, @@ -549,11 +628,19 @@ type FileReexportClosure = ReadonlyMap; * level import graph. Replaces the legacy recursive * `followReexportChain` crawl with a bounded, stack-safe pass: * - * 1. **Sub-graph.** Build a directed graph whose edges are - * `reexport` and `wildcard` drafts only (regular imports do not - * contribute to the export surface, and `namespace`/ - * `reexport-namespace` are terminal — their target def lives in - * `localDefs`). + * 1. **Sub-graph.** Build a directed graph whose edges are `wildcard` + * drafts, `reexport` drafts, and `named`/`alias` drafts flagged + * `reexportsName` by their provider. `namespace`/`reexport-namespace` + * are terminal — their target def lives in `localDefs` — and are + * excluded on `base.kind`, after any `isNamespaceImport` + * reclassification. + * + * The flagged-named case is what languages with no dedicated + * re-export form need (today: Python, whose module-level + * `from m import x` both binds and republishes). For those providers + * the sub-graph is close to the file-level named-import graph, NOT a + * sparse barrel graph — measured ~20× more edges on the CPython + * stdlib — so read every bound below with that input class in mind. * 2. **SCC condensation.** Run the same iterative `tarjanSccs` over * the sub-graph. Output is in reverse-topological order (leaves * first), so when we process an SCC every out-of-SCC neighbor @@ -567,21 +654,34 @@ type FileReexportClosure = ReadonlyMap; * the cycle; first-wins precedence keeps the map monotone * so the fixpoint converges in at most |SCC| hops). * - * **Precedence semantics — preserved from the recursive crawl.** + * **Precedence semantics.** * * Named re-exports take precedence over wildcards. * * Within each kind, declaration order wins (first match for a - * given exported name is kept; later drafts skip). + * given exported name is kept; later drafts skip). This is only sound + * where the language makes a duplicate export illegal — true for TS + * and Rust `kind: 'reexport'`, false for the flagged-named form, where + * the module namespace rebinds (last write wins) and `if`/`try` pairs + * execute exactly one branch. For those, an in-file collision on the + * same published name with two different in-workspace targets is + * genuinely ambiguous and is dropped instead of guessed — see + * `collectAmbiguousReexports`. * * **Complexity.** * * Pre-pass: O(V + E_re) for SCC, plus O(|SCC| × Σ drafts) per cyclic - * SCC. For tree-shaped barrel graphs (the common case) it - * collapses to O(E_re) total. - * * Per-edge lookup at finalize time: O(1). + * SCC. Tree-shaped barrel graphs collapse to O(E_re) total; the + * flagged-named input class does not — the CPython stdlib produces 10 + * cyclic SCCs here where TypeScript-shaped input produced none. + * * Per-edge lookup at finalize time: O(1). Target `localDefs` are + * indexed by simple name on first use (`findExportByName`), so the + * per-hop cost is O(1) rather than a linear scan of the target file. * * `transitiveVia` preserves the exact file path chain for diagnostics * and graph provenance. Building those arrays copies the inherited path, - * which is O(depth²) in a pathological single-name barrel chain; practical - * TypeScript barrel chains are shallow enough that we keep exact paths - * instead of capping or summarizing them. + * which is Θ(depth²) in a single-name chain, and Θ(|SCC|²) for a cyclic + * SCC whose chain tracks the cycle. `MAX_REEXPORT_DEPTH = 100` bounded + * this until it was removed in `fc919ad6` for shallow TypeScript + * barrels; **nothing bounds it now**, and the flagged-named class feeds + * it far deeper input. Real `__init__.py` chains measure ≤ ~6, so this + * is a known unenforced assumption, not a live regression. * * Pathological deep chains that previously needed * `MAX_REEXPORT_DEPTH=100` to bound stack growth now resolve * in full and are bounded only by available memory — the @@ -595,19 +695,22 @@ function buildReexportClosures( const closures = new Map>(); for (const file of files) closures.set(file.filePath, new Map()); - // ── Step 1: build the re-export sub-graph (only resolvable - // reexport/wildcard targets contribute edges). + // ── Step 1: build the re-export sub-graph (only resolvable wildcard / + // reexport / flagged-named targets contribute edges), and collect the + // per-file ambiguous names in the same walk. const subGraph = new Map>(); + const ambiguous = new Map>(); for (const file of files) { const targets = new Set(); const drafts = edgeIndex.get(file.filePath); if (drafts !== undefined) { for (const d of drafts) { - if (d.source.kind !== 'reexport' && d.source.kind !== 'wildcard') continue; + if (!contributesReexportEdge(d)) continue; if (d.targetFile === null) continue; if (!byFilePath.has(d.targetFile)) continue; targets.add(d.targetFile); } + ambiguous.set(file.filePath, collectAmbiguousReexports(drafts, byFilePath)); } subGraph.set(file.filePath, targets); } @@ -623,7 +726,7 @@ function buildReexportClosures( if (!scc.isCycle) { const filePath = scc.files[0]; if (filePath !== undefined) { - populateFileClosure(filePath, byFilePath, edgeIndex, closures); + populateFileClosure(filePath, byFilePath, edgeIndex, closures, ambiguous); } continue; } @@ -637,7 +740,7 @@ function buildReexportClosures( progressed = false; iter++; for (const filePath of scc.files) { - if (populateFileClosure(filePath, byFilePath, edgeIndex, closures)) { + if (populateFileClosure(filePath, byFilePath, edgeIndex, closures, ambiguous)) { progressed = true; } } @@ -647,6 +750,95 @@ function buildReexportClosures( return closures; } +/** + * Does this import republish names from its target under the *importing* file, + * making it an edge in the re-export sub-graph? + * + * `reexport` and `wildcard` are the explicit forms; `named`/`alias` drafts + * flagged `reexportsName` cover providers whose ordinary import syntax also + * republishes (see that field on `ParsedImport` for the contract). + * + * Tested on `base.kind`, not `source.kind`: `isNamespaceImport` can reclassify + * a `named` draft to `namespace` (Python's `from . import submodule`), and a + * namespace import aliases the target *module* — it publishes no name, so + * admitting it would republish whatever def happens to share the module's + * simple name. + */ +function contributesReexportEdge(draft: ImportEdgeDraft): boolean { + if (draft.base.kind === 'namespace') return false; + if (draft.source.kind === 'wildcard') return true; + return isNamedReexport(draft); +} + +/** + * Named (non-wildcard) re-export. The narrowed type lets `populateFileClosure` + * read `localName` (the name this file publishes) and `importedName` (the name + * the target exports) without re-discriminating on `kind`. + */ +function isNamedReexport(draft: ImportEdgeDraft): draft is ImportEdgeDraft & { + readonly source: Extract; +} { + if (draft.base.kind === 'namespace') return false; + const source = draft.source; + if (source.kind === 'reexport') return true; + return (source.kind === 'named' || source.kind === 'alias') && source.reexportsName === true; +} + +/** + * Names this file publishes ambiguously, which the closure must decline to + * answer for rather than guess at. + * + * Declaration-order first-wins is sound only where a duplicate export is + * illegal — two `export { X } from …` is a TypeScript compile error, so the + * rule never fires. The flagged-named form has no such guarantee: CPython's + * module namespace rebinds, so + * + * from .v1 import Client # legacy, left behind + * from .v2 import Client # the actual public Client + * + * binds `v2`, and first-wins would attribute every `from pkg import Client` in + * the repo to the dead implementation. Last-wins is not the answer either — + * for the equally common `try:`/`except ImportError:` and `if + * sys.version_info` pairs exactly one branch runs, and which one is not + * decidable here. So both directions are wrong on real code and the entry is + * dropped: the importer stays unresolved, which is exactly the pre-#2864 + * answer, and the file-level IMPORTS edge is unaffected. + * + * Computed once per file from data phase 0 froze (`edgeIndex`, `targetFile`) + * and never revised, so the closure map stays monotone and the `|SCC| + 1` + * fixpoint cap keeps the meaning it has above. A set that could grow mid- + * fixpoint would need retraction to propagate to files that already inherited + * the name, and would break both. + * + * Only two flagged drafts resolving to two *different in-workspace files* + * count. Duplicates of the same target are harmless, and an unresolvable + * target (`null` — the `try: import ujson / except: import json` shape, both + * external) never entered the closure to begin with. + * + * ponytail: named-vs-named only. Wildcard-vs-wildcard collisions are also + * first-wins today, but their inherited half depends on target closures that + * are still filling in, so detecting them needs a set that grows during the + * fixpoint — the thing this pre-pass exists to avoid. + */ +function collectAmbiguousReexports( + drafts: readonly ImportEdgeDraft[], + byFilePath: ReadonlyMap, +): ReadonlySet { + const firstTarget = new Map(); + const conflicting = new Set(); + for (const draft of drafts) { + if (!isNamedReexport(draft)) continue; + if (draft.source.kind === 'reexport') continue; // explicit form: duplicates are illegal upstream + const targetFile = draft.targetFile; + if (targetFile === null || !byFilePath.has(targetFile)) continue; + const localName = draft.source.localName; + const seen = firstTarget.get(localName); + if (seen === undefined) firstTarget.set(localName, targetFile); + else if (seen !== targetFile) conflicting.add(localName); + } + return conflicting; +} + /** * Populate one file's re-export closure for one pass. Returns `true` * iff the closure grew (signalling fixpoint progress to the caller). @@ -666,24 +858,29 @@ function populateFileClosure( byFilePath: ReadonlyMap, edgeIndex: ReadonlyMap, closures: Map>, + ambiguousByFile: ReadonlyMap>, ): boolean { const myClosure = closures.get(filePath); if (myClosure === undefined) return false; const before = myClosure.size; const drafts = edgeIndex.get(filePath); if (drafts === undefined) return false; + // Fixed for the whole run — see `collectAmbiguousReexports`. Consulted in + // both loops below: suppressing only the named one would let a later + // `import *` refill the name and reinstate an arbitrary winner. + const ambiguous = ambiguousByFile.get(filePath) ?? EMPTY_NAME_SET; // Named re-exports — precedence over wildcards, declaration order // first-wins for duplicates of the same exported name. for (const draft of drafts) { - if (draft.source.kind !== 'reexport') continue; + if (!isNamedReexport(draft)) continue; const targetFile = draft.targetFile; if (targetFile === null) continue; const targetModule = byFilePath.get(targetFile); if (targetModule === undefined) continue; const localName = draft.source.localName; - if (myClosure.has(localName)) continue; + if (ambiguous.has(localName) || myClosure.has(localName)) continue; const importedName = draft.source.importedName; const direct = findExportByName(targetModule.localDefs, importedName); @@ -695,7 +892,7 @@ function populateFileClosure( if (inherited !== undefined) { myClosure.set(localName, { def: inherited.def, - via: Object.freeze([targetFile, ...inherited.via]), + via: extendVia(targetFile, inherited.via), }); } // Else: target's closure is still empty (in-SCC, awaiting next @@ -714,16 +911,16 @@ function populateFileClosure( for (const def of targetModule.localDefs) { const name = deriveSimpleName(def); - if (name === null || myClosure.has(name)) continue; + if (name === null || ambiguous.has(name) || myClosure.has(name)) continue; myClosure.set(name, { def, via: Object.freeze([targetFile]) }); } const targetClosure = closures.get(targetFile); if (targetClosure !== undefined) { for (const [name, entry] of targetClosure) { - if (myClosure.has(name)) continue; + if (ambiguous.has(name) || myClosure.has(name)) continue; myClosure.set(name, { def: entry.def, - via: Object.freeze([targetFile, ...entry.via]), + via: extendVia(targetFile, entry.via), }); } } @@ -732,6 +929,35 @@ function populateFileClosure( return myClosure.size > before; } +/** + * Longest `transitiveVia` chain kept intact. Beyond this the tail is replaced + * by {@link VIA_TRUNCATED}, so the entry still says "this came through a long + * chain" without carrying it. + * + * Reinstates a bound the algorithm lost. Each hop copies the inherited path, + * so an uncapped chain is Θ(depth²) in both time and retained memory, and + * Θ(|SCC|²) for a cycle whose chain tracks it. `MAX_REEXPORT_DEPTH = 100` + * covered this until `fc919ad6` removed it — correctly, for the TypeScript + * barrels that were then the only input, which are shallow. Admitting + * flagged-named imports changes the input class, so the bound comes back. + * + * 32 against a measured real-world worst case of ~6 for `__init__.py` chains: + * five times the deepest chain anyone has, and it turns the quadratic into + * O(depth × 32). Safe to truncate because `ImportEdge.transitiveVia` has no + * production reader — it is diagnostic provenance, emitted and typed but not + * consumed by graph emission (`emitImportEdges` dedups on source→target and + * drops it). + */ +const MAX_VIA_LENGTH = 32; +const VIA_TRUNCATED = '…'; + +function extendVia(head: string, inherited: readonly string[]): readonly string[] { + if (inherited.length + 1 <= MAX_VIA_LENGTH) return Object.freeze([head, ...inherited]); + // Already truncated one hop down: re-truncating keeps the array at the cap + // rather than growing it by one per hop, which is the whole point. + return Object.freeze([head, ...inherited.slice(0, MAX_VIA_LENGTH - 2), VIA_TRUNCATED]); +} + /** * O(1) lookup into a precomputed re-export closure. Replaces the legacy * recursive `followReexportChain` traversal with a single map indexing. @@ -792,15 +1018,52 @@ function findExportByName( // // See `gitnexus/test/integration/resolvers/typescript-hof-callbacks.test.ts` // for the cross-file regression this rule prevents. - let fallback: SymbolDefinition | undefined; - for (const d of defs) { - if (deriveSimpleName(d) !== name) continue; - if (isCallableOrTypeLike(d.type)) return d; - if (fallback === undefined) fallback = d; - } - return fallback; + return indexExportsByName(defs).get(name); } +/** + * `simple name → winning def` for one file's `localDefs`, built once and + * memoized on the array itself. + * + * Every caller of `findExportByName` sits in a loop that revisits the same + * target files: the phase-3 fixpoint rescans a target once per iteration, and + * `populateFileClosure` scans once per admitted re-export — which for a + * provider setting `reexportsName` is every named import in the file, where it + * used to be zero. Keeping the scan turned that into O(edges × defs). + * + * Safe to key on identity because `FinalizeFile.localDefs` is documented static + * input that the fixpoint never mutates; a `WeakMap` ties each index to its + * array's lifetime with no cross-pass state to invalidate. Same shape as the + * `defById` map `materializeBindings` already builds for the same reason. + */ +const EXPORTS_BY_NAME = new WeakMap< + readonly SymbolDefinition[], + ReadonlyMap +>(); + +function indexExportsByName( + defs: readonly SymbolDefinition[], +): ReadonlyMap { + const cached = EXPORTS_BY_NAME.get(defs); + if (cached !== undefined) return cached; + const index = new Map(); + for (const d of defs) { + const name = deriveSimpleName(d); + if (name === null) continue; + const existing = index.get(name); + // First match wins within a tier; a callable displaces a stored value + // shadow but never another callable — identical to the linear scan's + // "first callable if any, else first match". + if (existing === undefined) index.set(name, d); + else if (!isCallableOrTypeLike(existing.type) && isCallableOrTypeLike(d.type)) + index.set(name, d); + } + EXPORTS_BY_NAME.set(defs, index); + return index; +} + +const EMPTY_NAME_SET: ReadonlySet = new Set(); + const CALLABLE_OR_TYPE_LIKE: ReadonlySet = new Set([ 'Function', 'Method', @@ -874,6 +1137,35 @@ function expandWildcard( kind: 'wildcard-expanded', targetModuleScope: edge.targetModuleScope, targetDefId: def.nodeId, + // Every expanded edge inherits the presence facts of the ONE statement it + // came from. They are built fresh rather than spread from `edge` because + // `localName`, `targetExportedName` and `targetDefId` all differ per name + // — which is exactly how a property added to the wildcard edge upstream + // gets silently dropped here, and how `runsOnlyWhenCalled` was. + // + // `runsOnlyWhenCalled`: Ruby's `def f; require './m'; end` is one + // statement inside one method body — and every Ruby `require` is a + // wildcard, since the required file's whole surface becomes visible — so + // each name it brings in is bound only when `f` runs. Losing the flag + // here re-reports the pair as an initialization dependency and + // suppresses nothing — it INVENTS a cycle (`check --cycles`), which is + // why this is carried and not derived. + // + // Ruby is the reachable spelling. Python has no function-local + // `from x import *` — it is a SyntaxError — and Rust's `fn f() { use + // m::*; }`, which IS legal, is not deferred at all: `use` is a + // compile-time path alias, so the Rust provider opts out of the position + // rule (`LanguageProvider.importsExecuteWhereWritten`). + // + // `typeOnly`: unreachable today and deliberately kept. No provider emits + // a type-only wildcard — `reexport-wildcard` returns `kind: 'wildcard'` + // with no `typeOnly` because `export type *` is unparseable by the + // vendored grammar (documented on `ParsedImport`'s `wildcard` variant). + // It is propagated so the day that gap closes does not silently + // reintroduce this same defect for erasure. Do not delete it as dead + // code; `typeOnlyFor` is the gate that decides whether it can ever be + // set, and it is where the correspondence is enforced. + ...carriedPresenceFlags(edge), }); } return expanded; diff --git a/gitnexus-shared/src/scope-resolution/reference-site.ts b/gitnexus-shared/src/scope-resolution/reference-site.ts index 6629dacd3..b559d32e3 100644 --- a/gitnexus-shared/src/scope-resolution/reference-site.ts +++ b/gitnexus-shared/src/scope-resolution/reference-site.ts @@ -82,6 +82,28 @@ export interface ReferenceSite { * otherwise, in which case resolution is unchanged. */ readonly rawQualifiedName?: string; + /** + * Top-level generic/template arguments the source wrote ON this reference — + * `class UserValidator : IValidator` yields `['string']` on the + * `inherits` site whose `name` is `IValidator`. + * + * `name` is the BASE name and stays that way: every lookup in resolution is + * keyed by it, and one declaration answers for every instantiation of itself. + * This records what the erasure threw away, so a consumer that needs the + * INSTANTIATION — receiver-bound interface dispatch, which must not fan a + * `IValidator` receiver out to an `IValidator` implementor + * (#2912) — can ask for it without re-parsing the source. + * + * Derived generically from the anchor capture's own text (see + * `collectReferenceSites`), so no language query change is needed: an emitter + * whose `@reference.inherits` anchor spans the whole base gets this for free, + * and one whose anchor is the bare name simply leaves it absent. + * + * ABSENT MEANS UNKNOWN, never "not generic" — the two are indistinguishable + * here, and only the first is safe to act on. Consumers must fail OPEN on + * absence (keep the target), matching `SymbolDefinition.typeParameters`. + */ + readonly typeArguments?: readonly string[]; /** Source-text range of this reference. */ readonly atRange: Range; /** diff --git a/gitnexus-shared/src/scope-resolution/symbol-definition.ts b/gitnexus-shared/src/scope-resolution/symbol-definition.ts index 896b0dc04..e90b0be85 100644 --- a/gitnexus-shared/src/scope-resolution/symbol-definition.ts +++ b/gitnexus-shared/src/scope-resolution/symbol-definition.ts @@ -107,6 +107,10 @@ export interface SymbolDefinition { * Unavailable callables still participate in overload selection, but a * selected unavailable target must suppress edge emission. */ isDeleted?: boolean; + /** True when the declaration identity was synthesized rather than written in + * source (for example an anonymous class). Consumers may use this only as a + * conservative priority hint; it does not change graph-node identity. */ + isSynthetic?: boolean; /** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */ ownerId?: string; /** #1982/#1993: bridge-held enclosing-namespace path (e.g. `NS1`, `Outer.Inner`) diff --git a/gitnexus-shared/src/scope-resolution/types.ts b/gitnexus-shared/src/scope-resolution/types.ts index dcd074400..80b961bda 100644 --- a/gitnexus-shared/src/scope-resolution/types.ts +++ b/gitnexus-shared/src/scope-resolution/types.ts @@ -119,8 +119,96 @@ export type ParsedImport = readonly importedName: string; readonly targetRaw: string; /** Provider-specific imported symbol category when module and symbol - * namespaces have distinct resolution rules (for example PHP). */ + * namespaces have distinct resolution rules (for example PHP). + * + * **Not** the same fact as {@link ParsedImport.typeOnly} — see the note + * on `typeOnly` below, which is documented on this variant. */ readonly importedSymbolKind?: 'type' | 'function' | 'const'; + /** + * Is this import ERASED before the module ever runs? + * + * TypeScript `import type { X } from './m'` and `import { type X }` are + * deleted by `tsc`: no `require`/`import` for `./m` survives in the + * emitted JavaScript, so the pair cannot force a module-INITIALIZATION + * order and cannot participate in an init cycle. That is the one thing + * `check --cycles` exists to find, so the fact has to survive from the + * syntax down to the emitted `IMPORTS` edge — see `ImportEdge.typeOnly` + * and `graph-bridge/imports-to-edges.ts`. + * + * **Distinct from `importedSymbolKind: 'type'`, which is NOT a substitute.** + * That field is a resolution-NAMESPACE category (PHP's `use function` / + * `use const` split), it exists only on this variant, and it says "the + * thing imported is a type". A symbol being a type says nothing about + * whether the import STATEMENT is erased, and PHP erases nothing at all. + * This field is about the statement's runtime existence, not the symbol's + * category. + * + * Set only by providers whose syntax marks it. Absent everywhere else, + * which reads as "not erased" — the fail-safe direction, since it only + * makes `check --cycles` over-report. + * + * That fail-safe matters more than it first looks, because an explicit + * `type` is a SUFFICIENT signal of erasure and not a necessary one. With + * neither `verbatimModuleSyntax` nor `importsNotUsedAsValues: preserve` + * set — this repo sets neither — `tsc` also elides a plain + * `import { SomeInterface }` whose bindings are every one of them used in + * type position. Those statements are erased at run time and carry no + * marker, so they stay tagged as initializing and `check --cycles` can + * still report a cycle that cannot exist. Closing that gap needs + * whole-program binding USE information, not import syntax, which is why + * this field stops at what the syntax states. + */ + readonly typeOnly?: boolean; + /** + * Was this import written inside a function body — so that it runs only + * when something CALLS that function, never while the module itself is + * initializing? + * + * Python's `def f(): from x import Y` and a CommonJS + * `function f() { const { Y } = require('./x'); }` are the spellings. + * Both are syntactically ordinary imports — no `kind` tells them apart + * from a top-level one, and nothing about the target does either. Only + * their POSITION defers them. + * + * Not every language's imports are like that, and the rule is wrong for + * the ones that are not: Rust's `use` and C/C++'s `#include` are legal + * in a function body and are deferred by NOTHING, because neither is an + * executed statement. Those providers opt out — see + * `LanguageProvider.importsExecuteWhereWritten`, below. + * + * **Why this cannot be re-derived downstream — the whole reason the + * field exists.** The natural place to decide it looks like the graph + * bridge, by walking the scope the finalized edges hang off; that is + * exactly what `graph-bridge/imports-to-edges.ts` once attempted, and it + * is dead code by construction. `finalize-algorithm.ts:295` publishes + * every file's finalized edges as + * `linkedByScope.set(file.moduleScope, …)`, so the map the bridge + * receives is keyed by the file's **Module** scope and by nothing else: + * the walk starts at a `Module` every time and answers `false` for every + * import in the tree. Finalize cannot recover the position either — + * `FinalizeFile.parsedImports` is a flat per-file `ParsedImport[]` with + * no scope attached. The extractor is the last stage that still knows + * where the statement sat (`scope-extractor.ts`, Pass 3), so it marks the + * fact here and it rides the edge from there — see + * {@link ImportEdge.runsOnlyWhenCalled}. + * + * Consumed by `check --cycles`, which asks "can these modules be + * initialized in any order?". A deferred import carries no + * initialization order, and deferring one is the standard way to BREAK + * an init cycle, so counting it reports the fix as the bug. + * + * Set by the central extractor for every language, not by providers — + * except that a provider may declare that its imports do not execute + * where they are written (`LanguageProvider.importsExecuteWhereWritten: + * false`) and be skipped entirely. C, C++, Rust and COBOL do. A `#include` + * or a `use` inside a function body is not deferred: the header is + * spliced and the path alias is resolved before anything runs, so the + * pair really is a dependency and the cycle it can form is real. + * + * Absent reads as "runs at initialization" — the fail-safe direction, + * since it only makes `check --cycles` over-report. + */ + readonly runsOnlyWhenCalled?: boolean; /** * Set by providers when `targetRaw` already names the imported symbol * rather than only its containing module. Consumers that compose @@ -128,6 +216,40 @@ export type ParsedImport = * duplicating `importedName`. */ readonly targetIncludesImportedName?: boolean; + /** + * Set by providers whose import syntax *also* republishes the name from + * the importing module, so a third file can import it from there. + * + * Python has no dedicated re-export form: a module-level + * `from pkg.impl import X` binds `X` locally **and** publishes it as + * `pkg.X`, which is the standard way a package `__init__.py` declares + * its public surface. Languages with an explicit form (TS `export … from`, + * Rust `pub use`) emit `kind: 'reexport'` instead and leave this unset. + * + * **The flag must track actual republication, not syntax.** Only a + * module-level statement publishes: the same `from m import X` inside a + * `def` or `class` body binds locally and puts nothing in the module + * namespace, so flagging it fabricates a re-export of a name no importer + * can reach. `if` / `try` / `for` / `with` do not suppress it — Python + * has no block scope. A provider that cannot tell these apart at + * interpret time must carry the fact down from its capture emitter, + * where the syntax node is still available. + * + * **Why not `kind: 'reexport'`.** Not because that form drops the local + * binding — `materializeBindings` creates a module-scope `BindingRef` + * for every linked edge, re-export included. It is that `reexport` + * changes what the binding *is*: `origin` flips to `'reexport'`, which + * carries different evidence weight and `ORIGIN_PRIORITY`, and it + * misreports the parse-time syntax Python actually wrote. A flag adds + * the export-surface fact without restating the import as something the + * source does not say. + * + * Consumed by `buildReexportClosures` (`finalize-algorithm.ts`), which + * also documents how ambiguous duplicates of one published name are + * handled — the precedence rules that hold for an explicit re-export do + * not carry over. + */ + readonly reexportsName?: boolean; } /** * Per-name import with rename. @@ -144,8 +266,18 @@ export type ParsedImport = readonly targetRaw: string; /** See the same field on the `named` variant. */ readonly importedSymbolKind?: 'type' | 'function' | 'const'; + /** See the same field on the `named` variant — including why it is not + * interchangeable with `importedSymbolKind`. Reaches this variant from + * `import type D from './m'` and `import { type X as Y } from './m'`. */ + readonly typeOnly?: boolean; + /** See the same field on the `named` variant. Reaches this variant from + * Python's `def f(): from x import Y as Z` and a CommonJS + * `function f() { const { Y: Z } = require('./x'); }`. */ + readonly runsOnlyWhenCalled?: boolean; /** See the same field on the `named` variant. */ readonly targetIncludesImportedName?: boolean; + /** See the same field on the `named` variant. */ + readonly reexportsName?: boolean; } /** * Qualified module handle, with or without rename. `importedName` is the @@ -165,6 +297,12 @@ export type ParsedImport = /** Module being aliased (e.g. `numpy` in `import numpy as np`). */ readonly importedName: string; readonly targetRaw: string; + /** See the same field on the `named` variant. Reaches this variant from + * TypeScript `import type * as N from './m'`. */ + readonly typeOnly?: boolean; + /** See the same field on the `named` variant. Reaches this variant from + * Python's `def f(): import numpy as np`. */ + readonly runsOnlyWhenCalled?: boolean; } /** * Syntactically-detectable parse-time re-export. Finalize may still produce @@ -186,6 +324,19 @@ export type ParsedImport = readonly targetRaw: string; /** Set when the re-export renames the symbol (e.g. `export { X as Y } from './y'`). */ readonly alias?: string; + /** See the same field on the `named` variant. Reaches this variant from + * TypeScript `export type { X } from './y'` and `export { type X } from './y'`. */ + readonly typeOnly?: boolean; + /** See the same field on the `named` variant. NO spelling reaches this + * variant today: the two providers that emit `reexport` are TypeScript + * / JavaScript, whose `export … from` is a module-top-level-only + * declaration, and Rust, whose `pub use` is a compile-time path alias + * that its provider exempts from the position rule outright + * (`LanguageProvider.importsExecuteWhereWritten`). Kept because the + * extractor sets the field with no `switch` on `kind`, so a re-export + * form that IS an executed statement would be tagged the moment one + * appears — not because anything sets it now. */ + readonly runsOnlyWhenCalled?: boolean; } /** * Wildcard import — brings every exported name from the target module into @@ -197,10 +348,26 @@ export type ParsedImport = * - Python `from foo import *` → `{ kind: 'wildcard', targetRaw: 'foo' }` * - JS `export * from './foo'` → `{ kind: 'wildcard', targetRaw: './foo' }` * - Rust `pub use foo::*` → `{ kind: 'wildcard', targetRaw: 'foo' }` + * + * No `typeOnly` here on purpose. The one syntax that would set it, + * TypeScript 5.0's `export type * from './m'`, is not parsed by the + * vendored tree-sitter-typescript grammar — it yields an `ERROR` node + * holding the bare `type` token, so the fact is not readable at the + * statement level (see `typescript/import-decomposer.ts`). Add the field + * with the grammar that can express it, not before. */ | { readonly kind: 'wildcard'; readonly targetRaw: string; + /** See the same field on the `named` variant. Present here although + * `typeOnly` is not: erasure is a syntactic fact this spelling cannot + * express, but POSITION is not — Ruby's `def f; require './m'; end` is + * a wildcard (everything in the required file becomes visible) and IS + * deferred. Python cannot reach it: `from x import *` inside a `def` is + * a SyntaxError. Rust's fn-local `use foo::*` is legal but not + * deferred — `use` does not execute + * (`LanguageProvider.importsExecuteWhereWritten`). */ + readonly runsOnlyWhenCalled?: boolean; } /** * Runtime-computed target — the import path is not a static literal at @@ -217,6 +384,9 @@ export type ParsedImport = readonly localName: string; /** Source text of the unresolved expression when available; `null` otherwise. */ readonly targetRaw: string | null; + /** See the same field on the `named` variant. Set by position like every + * other variant; this kind links no target, so nothing reads it here. */ + readonly runsOnlyWhenCalled?: boolean; } /** * Lazy / dynamic import whose target IS a static string literal at parse @@ -238,6 +408,10 @@ export type ParsedImport = | { readonly kind: 'dynamic-resolved'; readonly targetRaw: string; + /** See the same field on the `named` variant. Redundant on this kind — + * `import()` is already deferred wherever it is written — but set + * uniformly, because position is decided without consulting `kind`. */ + readonly runsOnlyWhenCalled?: boolean; } /** * Bare-source / side-effect import that introduces no local name binding @@ -253,6 +427,10 @@ export type ParsedImport = | { readonly kind: 'side-effect'; readonly targetRaw: string; + /** See the same field on the `named` variant. Reaches this variant from + * a bare CommonJS `function f() { require('./polyfill'); }` — the ESM + * spelling `import './polyfill'` cannot, being top-level only. */ + readonly runsOnlyWhenCalled?: boolean; }; /** @@ -348,6 +526,37 @@ export interface ImportEdge { | 'side-effect'; /** Re-export chain, for provenance (e.g., `['./y']` when re-exported via `./y`). */ readonly transitiveVia?: readonly string[]; + /** + * The import is erased before the module runs — see `ParsedImport`'s + * `typeOnly` on the `named` variant for the full note, including why + * `importedSymbolKind: 'type'` is a different fact and not a substitute. + * + * Carried straight from the `ParsedImport` by `makeEdgeDrafts`. The edge is + * still emitted: a type-only import is a real source-level dependency that + * `impact` and `trace` must see, and editing the target still breaks the + * importer's typecheck. What the flag removes is the claim that the pair + * forces an INITIALIZATION order. + */ + readonly typeOnly?: boolean; + /** + * The import was written inside a function body, so it runs only when that + * function is called — never during module initialization. See + * `ParsedImport`'s `runsOnlyWhenCalled` on the `named` variant for the full + * note, including why the consumer cannot re-derive this from the scope tree + * and therefore has to be told (`finalize-algorithm.ts:295`). + * + * Carried straight from the `ParsedImport` by `makeEdgeDrafts`, for the same + * reason `typeOnly` is: the edge is where `graph-bridge/imports-to-edges.ts` + * can still see it. The edge is still emitted either way — a deferred import + * is a real dependency. What the flag removes is the claim that the pair + * forces an INITIALIZATION order. + * + * Distinct from `kind === 'dynamic-resolved'`, which records the OTHER way an + * import can be deferred (`import('./m')`). Neither implies the other: a + * top-level `import()` is deferred with this flag unset, and a function-local + * `from x import Y` is deferred with an ordinary `named` kind. + */ + readonly runsOnlyWhenCalled?: boolean; /** Set to `'unresolved'` when the SCC fixpoint could not link this edge. */ readonly linkStatus?: 'unresolved'; } diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index ec55afb9c..53e1b5c69 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -8,15 +8,15 @@ "name": "gitnexus-web", "version": "0.0.0", "dependencies": { - "@langchain/anthropic": "^1.5.1", - "@langchain/core": "^1.2.3", + "@langchain/anthropic": "^1.5.8", + "@langchain/core": "^1.2.8", "@langchain/google-genai": "^2.2.0", - "@langchain/langgraph": "^1.4.8", + "@langchain/langgraph": "^1.4.9", "@langchain/ollama": "^1.3.0", "@langchain/openai": "^1.5.3", "@sigma/edge-curve": "^3.1.0", "@tailwindcss/vite": "^4.3.3", - "axios": "^1.18.1", + "axios": "^1.19.0", "d3": "^7.9.0", "dompurify": "^3.4.13", "gitnexus-shared": "file:../gitnexus-shared", @@ -28,37 +28,37 @@ "graphology-utils": "^2.3.0", "i18next": "^26.3.6", "i18next-browser-languagedetector": "^8.2.1", - "langchain": "^1.4.6", + "langchain": "^1.5.4", "lru-cache": "^11.5.2", - "lucide-react": "^1.23.0", + "lucide-react": "^1.31.0", "mermaid": "^11.16.1", "mnemonist": "^0.40.4", "pandemonium": "^2.4.0", "react": "^19.2.5", - "react-dom": "^19.2.7", - "react-i18next": "^17.0.11", + "react-dom": "^19.2.8", + "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.3", + "@types/react-dom": "^19.2.4", "@types/react-syntax-highlighter": "^15.5.13", - "@vercel/node": "^5.8.23", - "@vitejs/plugin-react": "^6.0.4", + "@vercel/node": "^5.10.2", + "@vitejs/plugin-react": "^6.0.5", "@vitest/coverage-v8": "^4.1.9", "jsdom": "^29.1.1", "tree-sitter-wasms": "^0.1.13", @@ -74,7 +74,7 @@ "../gitnexus-shared": { "version": "1.0.0", "devDependencies": { - "typescript": "^6.0.3" + "typescript": "^7.0.2" } }, "node_modules/@adobe/css-tools": { @@ -98,9 +98,9 @@ } }, "node_modules/@anthropic-ai/sdk": { - "version": "0.103.0", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.103.0.tgz", - "integrity": "sha512-1uG7RNgoHTUxzOXqSCODKt0UTVlxWiHk/2Tt2/uQJiPW7XzBeKVuJyd3Aw6T3LPyvZV/jDTnPLX7SaM70WLLjA==", + "version": "0.115.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.115.0.tgz", + "integrity": "sha512-BJrFIVyjNuU8lfDyIJTvlRYzgQg+zEl78BxE7fq8esULsGz9IRQvGtW5spq3tydmtjQb/GFdooKGdGsetpx+lQ==", "license": "MIT", "dependencies": { "json-schema-to-ts": "^3.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", @@ -1123,25 +1116,25 @@ } }, "node_modules/@langchain/anthropic": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@langchain/anthropic/-/anthropic-1.5.1.tgz", - "integrity": "sha512-j92zCCd5BFH3rHMRzc2wBmSKDoVpinof1oh8aFiAz9TWbSOc4tGU4n6bqwy/wP0GH1uO96zZHLGCHBMPgrxTNw==", + "version": "1.5.8", + "resolved": "https://registry.npmjs.org/@langchain/anthropic/-/anthropic-1.5.8.tgz", + "integrity": "sha512-KZWgIf+04M9XZHhgH1rVJkqw/C26DM4a4jKk4Qc4HaSbRawN2Dw5nDffna+IoaU/50ohTdyB3HOz9g8XFQYF2A==", "license": "MIT", "dependencies": { - "@anthropic-ai/sdk": "^0.103.0", + "@anthropic-ai/sdk": "^0.115.0", "zod": "^3.25.76 || ^4" }, "engines": { "node": ">=20" }, "peerDependencies": { - "@langchain/core": "^1.2.1" + "@langchain/core": "^1.2.9" } }, "node_modules/@langchain/core": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.2.3.tgz", - "integrity": "sha512-F+L5SsciykwDl7eDxacnhDTcWe1IF6jetzfkvI5PPfq6ogWHO7xcjU90SGh/3lqbbS0tgun+qF01KIqxawrCsA==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.2.9.tgz", + "integrity": "sha512-conzSEj9Zu1AyXJLXsSbgrtxtxinmI1yGqQ5CIJZSoV5rvv+yvQE/vgBnoySpBQ/bl3YPgj2FL/gbDjWykLSfg==", "license": "MIT", "dependencies": { "@cfworker/json-schema": "^4.0.2", @@ -1172,13 +1165,13 @@ } }, "node_modules/@langchain/langgraph": { - "version": "1.4.8", - "resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-1.4.8.tgz", - "integrity": "sha512-DN1Np1XefdBEbp1qBKlt39cwoL743AAGpR5Ipja0gY2YbWvsoQnOTIrjnj/orSAhaUYsdTKS8VSWdFzsHZo6Ig==", + "version": "1.4.9", + "resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-1.4.9.tgz", + "integrity": "sha512-EvD9rS66Cya09y6rbMgD3Ir8miAkJQFo7FyJOPRPO736Kz3y5TeyeBDOS8ctff/jRc788bPijHx2NVFM79Qqig==", "license": "MIT", "dependencies": { "@langchain/langgraph-checkpoint": "^1.1.3", - "@langchain/langgraph-sdk": "~1.9.26", + "@langchain/langgraph-sdk": "~1.9.28", "@langchain/protocol": "^0.0.18", "@standard-schema/spec": "1.1.0" }, @@ -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": { @@ -2589,9 +2544,9 @@ } }, "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", "dev": true, "license": "MIT", "peerDependencies": { @@ -2638,13 +2593,12 @@ } }, "node_modules/@vercel/build-utils": { - "version": "13.32.3", - "resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-13.32.3.tgz", - "integrity": "sha512-rYk9EKq8ThkBC1vz38jZ8DmmxtKBjN6EfEOEz1ORL74PLVvET/l++R0tNmPrPg3eP+GI852KdArDmJRNCY6EOw==", + "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.11.1", "cjs-module-lexer": "1.2.3", "es-module-lexer": "1.5.0" } @@ -2657,9 +2611,9 @@ "license": "MIT" }, "node_modules/@vercel/error-utils": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@vercel/error-utils/-/error-utils-2.2.0.tgz", - "integrity": "sha512-WFWiRxfPzoYWYifaj4thSKvAaZZwUOqD4k5GINRIgZgCiS2E3iAJbWbIsIZmkQdTecWFHcWGA6q48CjisgpOBA==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@vercel/error-utils/-/error-utils-2.2.1.tgz", + "integrity": "sha512-9DhP8jP7raLML4hGsBemxX5fXuQnu5xxMV+HjGygGbzEmVK/+KyJ3QP2Cw7PdF0uXdb9N0Qa4c3tRGH34ZX6vw==", "dev": true, "license": "Apache-2.0" }, @@ -2691,9 +2645,9 @@ } }, "node_modules/@vercel/node": { - "version": "5.8.23", - "resolved": "https://registry.npmjs.org/@vercel/node/-/node-5.8.23.tgz", - "integrity": "sha512-wigp1yONlJwFtPuyCrp6KI1umG78VhhEspNBXe2i9UOaxjjqLAR3DKiRQ/ivjvnDzV0SN7fuLxwLj+JcG0iwcQ==", + "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,10 +2655,10 @@ "@edge-runtime/primitives": "4.1.0", "@edge-runtime/vm": "3.2.0", "@types/node": "20.11.0", - "@vercel/build-utils": "13.32.3", - "@vercel/error-utils": "2.2.0", + "@vercel/build-utils": "14.2.0", + "@vercel/error-utils": "2.2.1", "@vercel/nft": "1.10.0", - "@vercel/static-config": "3.4.0", + "@vercel/static-config": "3.4.1", "async-listen": "3.0.0", "cjs-module-lexer": "1.2.3", "edge-runtime": "2.5.9", @@ -2738,36 +2692,10 @@ "dev": true, "license": "MIT" }, - "node_modules/@vercel/python-analysis": { - "version": "0.11.1", - "resolved": "https://registry.npmjs.org/@vercel/python-analysis/-/python-analysis-0.11.1.tgz", - "integrity": "sha512-EPPLuXJQhIDUx08H9nG76AR2HSgBquwe3OAX5s2w20M923iaWeGGVkhX/4yZ89CJfXEZgE1Aj/mX7lVHOVIcYA==", - "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.0", - "resolved": "https://registry.npmjs.org/@vercel/static-config/-/static-config-3.4.0.tgz", - "integrity": "sha512-wCq90CMUB//ggnFh77NQO1xaLFsS4LigQIqKrH6ohnr9Br/KI1FhlErx62WfCOuueWaW+LVsbLOqNXIUjK8t6A==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@vercel/static-config/-/static-config-3.4.1.tgz", + "integrity": "sha512-kJKTyOg25JDRgDkHEkc+vWlvURxmSQkVKyRPO4EEGD/8HpJT+4u9Z/VGxwnCZ6zZBxYPpma283qBsHwY0gXjfw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2788,9 +2716,9 @@ } }, "node_modules/@vitejs/plugin-react": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.4.tgz", - "integrity": "sha512-XcCQz0TBpBgljhj0gMuuDj49i6Ytqh5q1osT/Gp5uAVJUCTWxyskk/l1jwYYiu2xcNHHipdMz40EGfM1VdamVg==", + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz", + "integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==", "dev": true, "license": "MIT", "dependencies": { @@ -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", @@ -3131,13 +3052,13 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", - "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", + "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", + "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } @@ -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", @@ -5363,13 +5243,13 @@ "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" }, "node_modules/langchain": { - "version": "1.4.6", - "resolved": "https://registry.npmjs.org/langchain/-/langchain-1.4.6.tgz", - "integrity": "sha512-pwuFmGOyiMezptLVLrpb5jILirvYPGHI5uJCFHL5K5WPxMy2XuPLI5QNMKtoHkdiL6a2dLebqugKw87cneaESw==", + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/langchain/-/langchain-1.5.4.tgz", + "integrity": "sha512-9Rq6Ih77UOy3+7bCbxMJS16MRUJwfxuljU0yW2KOXDgEKWE8cmaZJE6ONEy4HdWGMsbj3qyv3vD5UvV7fvNksg==", "license": "MIT", "dependencies": { - "@langchain/langgraph": "^1.3.4", - "@langchain/langgraph-checkpoint": "^1.0.4", + "@langchain/langgraph": "^1.4.7", + "@langchain/langgraph-checkpoint": "^1.1.3", "langsmith": ">=0.5.0 <1.0.0", "zod": "^3.25.76 || ^4" }, @@ -5377,7 +5257,7 @@ "node": ">=20" }, "peerDependencies": { - "@langchain/core": "^1.2.0" + "@langchain/core": "^1.2.3" } }, "node_modules/langsmith": { @@ -5715,9 +5595,9 @@ } }, "node_modules/lucide-react": { - "version": "1.23.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.23.0.tgz", - "integrity": "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw==", + "version": "1.31.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.31.0.tgz", + "integrity": "sha512-G8u2eEtoHUnUa9f8lbvqDhCiORMnYLdUEo06EEG9MQvHQrInKcX3Pa2TH39MM5qyzRcWETxB0+aOwAPI1g1kEg==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -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", @@ -7393,33 +7273,33 @@ "license": "MIT" }, "node_modules/react": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", - "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", - "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.7" + "react": "^19.2.8" } }, "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" diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index e7e3c0fb3..09ac0b640 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -18,15 +18,15 @@ "test:e2e:report": "playwright show-report" }, "dependencies": { - "@langchain/anthropic": "^1.5.1", - "@langchain/core": "^1.2.3", + "@langchain/anthropic": "^1.5.8", + "@langchain/core": "^1.2.8", "@langchain/google-genai": "^2.2.0", - "@langchain/langgraph": "^1.4.8", + "@langchain/langgraph": "^1.4.9", "@langchain/ollama": "^1.3.0", "@langchain/openai": "^1.5.3", "@sigma/edge-curve": "^3.1.0", "@tailwindcss/vite": "^4.3.3", - "axios": "^1.18.1", + "axios": "^1.19.0", "d3": "^7.9.0", "dompurify": "^3.4.13", "gitnexus-shared": "file:../gitnexus-shared", @@ -38,37 +38,37 @@ "graphology-utils": "^2.3.0", "i18next": "^26.3.6", "i18next-browser-languagedetector": "^8.2.1", - "langchain": "^1.4.6", + "langchain": "^1.5.4", "lru-cache": "^11.5.2", - "lucide-react": "^1.23.0", + "lucide-react": "^1.31.0", "mermaid": "^11.16.1", "mnemonist": "^0.40.4", "pandemonium": "^2.4.0", "react": "^19.2.5", - "react-dom": "^19.2.7", - "react-i18next": "^17.0.11", + "react-dom": "^19.2.8", + "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.3", + "@types/react-dom": "^19.2.4", "@types/react-syntax-highlighter": "^15.5.13", - "@vercel/node": "^5.8.23", - "@vitejs/plugin-react": "^6.0.4", + "@vercel/node": "^5.10.2", + "@vitejs/plugin-react": "^6.0.5", "@vitest/coverage-v8": "^4.1.9", "jsdom": "^29.1.1", "tree-sitter-wasms": "^0.1.13", @@ -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", diff --git a/gitnexus-web/src/components/SettingsPanel.tsx b/gitnexus-web/src/components/SettingsPanel.tsx index 0c3a22aec..9b32ffd92 100644 --- a/gitnexus-web/src/components/SettingsPanel.tsx +++ b/gitnexus-web/src/components/SettingsPanel.tsx @@ -21,7 +21,13 @@ import { fetchOpenRouterModels, } from '../core/llm/settings-service'; import { getAuthToken, setAuthToken } from '../services/backend-client'; -import type { LLMSettings, LLMProvider } from '../core/llm/types'; +import type { LLMSettings, LLMProvider, MiniMaxThinkingMode } from '../core/llm/types'; +import { + getMiniMaxModelCapabilities, + MINIMAX_ANTHROPIC_BASE_URLS, + MINIMAX_DOCS_ROOTS, + MINIMAX_MODEL_IDS, +} from '../core/llm/types'; import { DEFAULT_OLLAMA_BASE_URL } from '../config/ui-constants'; import { ProviderConfigCard } from './settings/ProviderConfigCard'; import { SecretInput } from './settings/SecretInput'; @@ -341,6 +347,20 @@ export const SettingsPanel = ({ if (!isOpen) return null; + const miniMaxModel = settings.minimax?.model ?? MINIMAX_MODEL_IDS[0]; + const miniMaxCapabilities = getMiniMaxModelCapabilities(miniMaxModel); + const configuredMiniMaxThinkingMode = settings.minimax?.thinkingMode; + const miniMaxThinkingMode = + configuredMiniMaxThinkingMode && + miniMaxCapabilities?.thinkingModes.includes(configuredMiniMaxThinkingMode) + ? configuredMiniMaxThinkingMode + : (miniMaxCapabilities?.thinkingModes[0] ?? configuredMiniMaxThinkingMode ?? 'adaptive'); + const miniMaxBaseUrl = settings.minimax?.baseUrl ?? MINIMAX_ANTHROPIC_BASE_URLS.global_en; + const miniMaxDocsRoot = + miniMaxBaseUrl === MINIMAX_ANTHROPIC_BASE_URLS.cn_zh + ? MINIMAX_DOCS_ROOTS.cn_zh + : MINIMAX_DOCS_ROOTS.global_en; + const providers: LLMProvider[] = [ 'openai', 'gemini', @@ -864,7 +884,7 @@ export const SettingsPanel = ({ value: settings.minimax?.apiKey ?? '', placeholder: t('settings:providers.minimax.apiKeyPlaceholder'), helperText: t('settings:providers.minimax.helperText'), - helperLink: 'https://platform.minimax.io', + helperLink: miniMaxDocsRoot, helperLinkLabel: t('settings:providers.minimax.helperLinkLabel'), isVisible: !!showApiKey['minimax'], onChange: (value) => @@ -875,16 +895,79 @@ export const SettingsPanel = ({ onToggleVisibility: () => toggleApiKeyVisibility('minimax'), }} model={{ - value: settings.minimax?.model ?? 'MiniMax-M2.5', + value: miniMaxModel, placeholder: t('settings:providers.minimax.modelPlaceholder'), onChange: (value) => setSettings((prev) => ({ ...prev, - minimax: { ...prev.minimax!, model: value }, + minimax: { + ...prev.minimax!, + model: value, + thinkingMode: + getMiniMaxModelCapabilities(value)?.thinkingModes[0] ?? + prev.minimax?.thinkingMode, + }, })), helperText: t('settings:providers.minimax.helperModel'), }} - /> + > +
+ + +
+ +
+ + + {miniMaxCapabilities && ( +

+ {t('settings:providers.minimax.capabilities', { + contextWindow: miniMaxCapabilities.contextWindow.toLocaleString(), + modalities: miniMaxCapabilities.inputModalities.join(', '), + })} +

+ )} +
+ )} {/* DeepSeek Settings */} diff --git a/gitnexus-web/src/core/llm/agent.ts b/gitnexus-web/src/core/llm/agent.ts index c10748fd0..555cf0d10 100644 --- a/gitnexus-web/src/core/llm/agent.ts +++ b/gitnexus-web/src/core/llm/agent.ts @@ -20,6 +20,7 @@ import { ChatOllama } from '@langchain/ollama'; import type { BaseChatModel } from '@langchain/core/language_models/chat_models'; import { createGraphRAGTools, type GraphRAGBackend } from './tools'; import type { + AgentUserContent, ProviderConfig, OpenAIConfig, AzureOpenAIConfig, @@ -32,7 +33,9 @@ import type { DeepSeekConfig, AgentStreamChunk, AgentHistoryMessage, + MiniMaxThinkingMode, } from './types'; +import { getMiniMaxModelCapabilities, MINIMAX_ANTHROPIC_BASE_URLS } from './types'; import { type CodebaseContext, buildDynamicSystemPrompt, @@ -275,14 +278,28 @@ export const createChatModel = (config: ProviderConfig): BaseChatModel => { throw new Error('MiniMax API key is required but was not provided'); } + const capabilities = getMiniMaxModelCapabilities(minimaxConfig.model); + const requestedThinkingMode = minimaxConfig.thinkingMode; + const thinkingMode: MiniMaxThinkingMode | undefined = + requestedThinkingMode && capabilities?.thinkingModes.includes(requestedThinkingMode) + ? requestedThinkingMode + : (capabilities?.thinkingModes[0] ?? requestedThinkingMode); + const thinking = + thinkingMode && thinkingMode !== 'always_on' ? { type: thinkingMode } : undefined; + const temperature = + thinkingMode === 'adaptive' || thinkingMode === 'always_on' + ? undefined + : (minimaxConfig.temperature ?? 0.1); + return new ChatAnthropic({ anthropicApiKey: minimaxConfig.apiKey, model: minimaxConfig.model, - temperature: minimaxConfig.temperature ?? 0.1, + ...(temperature !== undefined ? { temperature } : {}), maxTokens: minimaxConfig.maxTokens ?? 8192, streaming: true, + ...(thinking ? { thinking } : {}), clientOptions: { - baseURL: 'https://api.minimax.io/anthropic', + baseURL: minimaxConfig.baseUrl ?? MINIMAX_ANTHROPIC_BASE_URLS.global_en, }, }); } @@ -393,7 +410,7 @@ export const createGraphRAGAgent = ( /** * Message type for agent conversation */ -export type AgentMessage = { role: 'user'; content: string } | AgentHistoryMessage; +export type AgentMessage = { role: 'user'; content: AgentUserContent } | AgentHistoryMessage; export interface AgentRuntimeOptions { /** Capture assistant/tool messages for providers that require exact transcript replay. */ @@ -412,7 +429,9 @@ const isAbortError = (error: unknown, signal?: AbortSignal): boolean => { export const buildLangChainMessages = (messages: AgentMessage[]): BaseMessage[] => messages.map((message) => { if (message.role === 'user') { - return new HumanMessage(message.content); + return typeof message.content === 'string' + ? new HumanMessage(message.content) + : new HumanMessage({ content: message.content as any }); } if (message.role === 'tool') { return new ToolMessage({ @@ -542,6 +561,7 @@ export async function* streamAgentResponse( // Handle content that can be string or array of content blocks let content: string = ''; + let thinkingContent: string = ''; if (typeof rawContent === 'string') { content = rawContent; } else if (Array.isArray(rawContent)) { @@ -550,6 +570,14 @@ export async function* streamAgentResponse( .filter((block: any) => block.type === 'text' || typeof block === 'string') .map((block: any) => (typeof block === 'string' ? block : block.text || '')) .join(''); + thinkingContent = rawContent + .filter((block: any) => block?.type === 'thinking') + .map((block: any) => block.thinking || '') + .join(''); + } + + if (thinkingContent) { + yield { type: 'reasoning', reasoning: thinkingContent }; } // If chunk has content, stream it diff --git a/gitnexus-web/src/core/llm/settings-service.ts b/gitnexus-web/src/core/llm/settings-service.ts index 79a7a4309..fb2591172 100644 --- a/gitnexus-web/src/core/llm/settings-service.ts +++ b/gitnexus-web/src/core/llm/settings-service.ts @@ -19,12 +19,32 @@ import { GLMConfig, DeepSeekConfig, ProviderConfig, + MINIMAX_MODEL_IDS, } from './types'; import { DEFAULT_OPENROUTER_BASE_URL, DEFAULT_OLLAMA_BASE_URL } from '../../config/ui-constants'; import { resilientFetch } from 'gitnexus-shared'; const STORAGE_KEY = 'gitnexus-llm-settings'; +const mergeMiniMaxSettings = ( + stored?: LLMSettings['minimax'], +): NonNullable => { + const merged = { + ...DEFAULT_LLM_SETTINGS.minimax, + ...stored, + }; + + if (!(MINIMAX_MODEL_IDS as readonly string[]).includes(merged.model ?? '')) { + return { + ...merged, + model: DEFAULT_LLM_SETTINGS.minimax?.model, + thinkingMode: DEFAULT_LLM_SETTINGS.minimax?.thinkingMode, + }; + } + + return merged; +}; + const mergeWithDefaults = (parsed?: Partial | null): LLMSettings => ({ ...DEFAULT_LLM_SETTINGS, ...parsed, @@ -52,10 +72,7 @@ const mergeWithDefaults = (parsed?: Partial | null): LLMSettings => ...DEFAULT_LLM_SETTINGS.openrouter, ...parsed?.openrouter, }, - minimax: { - ...DEFAULT_LLM_SETTINGS.minimax, - ...parsed?.minimax, - }, + minimax: mergeMiniMaxSettings(parsed?.minimax), glm: { ...DEFAULT_LLM_SETTINGS.glm, ...parsed?.glm, @@ -437,7 +454,7 @@ export const getAvailableModels = (provider: LLMProvider): string[] => { case 'ollama': return ['llama3.2', 'llama3.1', 'mistral', 'codellama', 'deepseek-coder']; case 'minimax': - return ['MiniMax-M2.5', 'MiniMax-M2.5-highspeed']; + return [...MINIMAX_MODEL_IDS]; case 'glm': return ['GLM-5', 'GLM-5-Turbo', 'GLM-4.7', 'GLM-4.5']; case 'deepseek': diff --git a/gitnexus-web/src/core/llm/tools.ts b/gitnexus-web/src/core/llm/tools.ts index a702e7db8..421725be3 100644 --- a/gitnexus-web/src/core/llm/tools.ts +++ b/gitnexus-web/src/core/llm/tools.ts @@ -13,8 +13,12 @@ import { tool } from '@langchain/core/tools'; import { z } from 'zod'; -import { NODE_TABLES, REL_TYPES } from 'gitnexus-shared'; -import type { EnrichedSearchResult, GrepResult } from '../../services/backend-client'; +import { NODE_TABLES, REL_TYPES, scoreImpactRisk, unusedAxesForImpactWalk } from 'gitnexus-shared'; +import type { + EnrichedSearchResult, + GrepOptions, + GrepResponse, +} from '../../services/backend-client'; /** * Tool names registered by createGraphRAGTools — kept in sync with each tool's `name` @@ -44,7 +48,7 @@ export interface GraphRAGBackend { query: string, opts?: { limit?: number; mode?: 'hybrid' | 'semantic' | 'bm25'; enrich?: boolean }, ) => Promise; - grep: (pattern: string, limit?: number) => Promise; + grep: (pattern: string, limit?: number, opts?: GrepOptions) => Promise; readFile: (filePath: string) => Promise; } @@ -375,20 +379,22 @@ MATCH (n:Function {id: emb.nodeId}) RETURN n`, } const limit = maxResults ?? 100; - const fullPattern = fileFilter - ? `(?=.*${fileFilter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}).*${pattern}` - : pattern; - - const results = await backendGrep(fullPattern, limit); + const { results, timedOut } = await backendGrep(pattern, limit, { + fileFilter, + caseSensitive, + }); + const timeoutMsg = timedOut + ? '\n\n(Scan timed out after a few seconds — results may be incomplete)' + : ''; if (results.length === 0) { - return `No matches for "${pattern}"${fileFilter ? ` in files matching "${fileFilter}"` : ''}`; + return `No matches for "${pattern}"${fileFilter ? ` in files matching "${fileFilter}"` : ''}${timeoutMsg}`; } const formatted = results.map((r) => `${r.filePath}:${r.line}: ${r.text}`).join('\n'); const truncatedMsg = results.length >= limit ? `\n\n(Showing first ${limit} results)` : ''; - return `Found ${results.length} matches:\n\n${formatted}${truncatedMsg}`; + return `Found ${results.length} matches:\n\n${formatted}${truncatedMsg}${timeoutMsg}`; } catch (error) { return `Grep error: ${error instanceof Error ? error.message : String(error)}`; } @@ -396,16 +402,20 @@ MATCH (n:Function {id: emb.nodeId}) RETURN n`, { name: 'grep', description: - 'Search for exact text patterns across all files using regex. Use for finding specific strings, error messages, TODOs, variable names, etc.', + 'Search file contents with a regular expression (server executes it as a real regex — alternation like "sign|Sign" works). Matches are case-insensitive unless caseSensitive is set. fileFilter keeps only files whose path contains the substring. Each call caps at maxResults matches (default 100) and the server stops after a few seconds (the tool will say so if the scan was incomplete), so prefer precise patterns over catch-alls.', schema: z.object({ pattern: z .string() - .describe('Regex pattern to search for (e.g., "TODO", "console\\.log", "API_KEY")'), + .describe( + 'Regex pattern to search for (e.g., "TODO|FIXME", "console\\.log", "signOrder")', + ), fileFilter: z .string() .optional() .nullable() - .describe('Only search files containing this string (e.g., ".ts", "src/api")'), + .describe( + 'Only search files whose path contains this substring (e.g., ".ts", "src/api", "Controller.java")', + ), caseSensitive: z .boolean() .optional() @@ -1219,7 +1229,7 @@ MATCH (n:Function {id: emb.nodeId}) RETURN n`, const targetFileName = (targetFilePath || target).split('/').pop() || target; const baseName = targetFileName.replace(/\.[^/.]+$/, ''); try { - const hints = await backendGrep(`\\b${escapeRegex(baseName)}\\b`, 15); + const { results: hints } = await backendGrep(`\\b${escapeRegex(baseName)}\\b`, 15); const filtered = hints.filter((h) => h.filePath !== targetFilePath); if (filtered.length > 0) { @@ -1275,6 +1285,9 @@ MATCH (n:Function {id: emb.nodeId}) RETURN n`, stepCount: number | null; }> = []; let affectedClusters: Array<{ label: string; hits: number; impact: string }> = []; + let processQueryFailed = false; + let clusterQueryFailed = false; + let clusterClassificationFailed = false; if (trimmedIds.length > 0) { const processQuery = ` @@ -1302,9 +1315,23 @@ MATCH (n:Function {id: emb.nodeId}) RETURN n`, : ''; const [processRes, clusterRes, directClusterRes] = await Promise.all([ - executeQuery(processQuery), - executeQuery(clusterQuery), - directClusterQuery ? executeQuery(directClusterQuery) : Promise.resolve([]), + executeQuery(processQuery).catch((err) => { + processQueryFailed = true; + if (import.meta.env.DEV) console.warn('Impact process enrichment failed:', err); + return []; + }), + executeQuery(clusterQuery).catch((err) => { + clusterQueryFailed = true; + if (import.meta.env.DEV) console.warn('Impact cluster enrichment failed:', err); + return []; + }), + directClusterQuery + ? executeQuery(directClusterQuery).catch((err) => { + clusterClassificationFailed = true; + if (import.meta.env.DEV) console.warn('Impact cluster enrichment failed:', err); + return []; + }) + : Promise.resolve([]), ]); const directClusterSet = new Set(); @@ -1323,7 +1350,11 @@ MATCH (n:Function {id: emb.nodeId}) RETURN n`, affectedClusters = clusterRes.map((row: any) => { const label = Array.isArray(row) ? row[0] : row.label; const hits = Array.isArray(row) ? row[1] : row.hits; - const impact = directClusterSet.has(label) ? 'direct' : 'indirect'; + const impact = clusterClassificationFailed + ? 'classification-unavailable' + : directClusterSet.has(label) + ? 'direct' + : 'indirect'; return { label, hits, impact }; }); } @@ -1331,19 +1362,25 @@ MATCH (n:Function {id: emb.nodeId}) RETURN n`, const directCount = depth1.length; const processCount = affectedProcesses.length; const clusterCount = affectedClusters.length; - let risk = 'LOW'; - if (directCount >= 30 || processCount >= 5 || clusterCount >= 5 || totalAffected >= 200) { - risk = 'CRITICAL'; - } else if ( - directCount >= 15 || - processCount >= 3 || - clusterCount >= 3 || - totalAffected >= 100 - ) { - risk = 'HIGH'; - } else if (directCount >= 5 || totalAffected >= 30) { - risk = 'MEDIUM'; - } + const enrichmentCapped = allNodeIds.length > maxIdsForContext; + const unusedAxes = unusedAxesForImpactWalk({ + isFileTarget: false, + skipEnrichment: false, + maxChunks: 10, + processQueryFailed, + moduleQueryFailed: clusterQueryFailed, + impactedCount: totalAffected, + enrichmentTruncated: enrichmentCapped, + }); + const scored = scoreImpactRisk({ + direction, + directCount, + processCount, + moduleCount: clusterCount, + impactedCount: totalAffected, + unusedAxes, + }); + const { risk, riskSharedAxes, riskScale } = scored; // ===== COMPACT TABULAR OUTPUT ===== const lines: string[] = [ @@ -1351,22 +1388,42 @@ MATCH (n:Function {id: emb.nodeId}) RETURN n`, `Confidence: High ${confidenceBuckets.high} | Medium ${confidenceBuckets.medium} | Low ${confidenceBuckets.low}`, ``, `AFFECTED PROCESSES:`, - ...(affectedProcesses.length > 0 - ? affectedProcesses.map( - (p) => - `- ${p.label} - BROKEN at step ${p.minStep ?? '?'} (${p.hits} symbols, ${p.stepCount ?? '?'} steps)`, - ) - : ['- None found']), + ...(processQueryFailed + ? ['- Unavailable (enrichment query failed)'] + : affectedProcesses.length > 0 + ? affectedProcesses.map( + (p) => + `- ${p.label} - BROKEN at step ${p.minStep ?? '?'} (${p.hits} symbols, ${p.stepCount ?? '?'} steps)`, + ) + : ['- None found']), ``, `AFFECTED CLUSTERS:`, - ...(affectedClusters.length > 0 - ? affectedClusters.map((c) => `- ${c.label} (${c.impact}, ${c.hits} symbols)`) - : ['- None found']), + ...(clusterQueryFailed + ? ['- Unavailable (enrichment query failed)'] + : affectedClusters.length > 0 + ? affectedClusters.map((c) => `- ${c.label} (${c.impact}, ${c.hits} symbols)`) + : ['- None found']), ``, - `RISK: ${risk}`, + `RISK: ${risk} (edit gate — warn on HIGH/CRITICAL)`, + `Shared-axes: ${riskSharedAxes} (File vs symbol compare only; do not waive a HIGH risk warning)`, + `Note: this Graph-RAG surface expands File targets to in-file symbols before enrichment, so process/cluster axes are comparable here when enrichment succeeds. MCP File impact does not.`, + ...(riskScale.comparableAcrossKinds + ? [] + : [ + `Note: process/module axes were unused (${riskScale.unusedAxes.map((a) => a.reason).join(', ')}).`, + ]), + ...(risk === 'UNKNOWN' && (processQueryFailed || clusterQueryFailed) + ? ['Note: risk is unresolved because enrichment failed; retry before editing.'] + : []), + ...(enrichmentCapped + ? [`Note: process/cluster enrichment is partial (first ${maxIdsForContext} symbols).`] + : []), + ...(clusterClassificationFailed + ? ['Note: direct/indirect cluster classification is unavailable.'] + : []), `- Direct callers: ${directCount}`, - `- Processes affected: ${processCount}`, - `- Clusters affected: ${clusterCount}`, + `- Processes affected: ${processQueryFailed ? 'unavailable' : processCount}`, + `- Clusters affected: ${clusterQueryFailed ? 'unavailable' : clusterCount}`, ``, ]; @@ -1472,7 +1529,9 @@ relationTypes filter (optional): Additional output sections: - Affected processes (with step impact) - Affected clusters (direct/indirect) -- Risk summary (based on direct callers, processes, clusters)`, +- RISK is the edit gate: warn before edits on HIGH/CRITICAL; UNKNOWN requires retry or corroboration +- Shared-axes risk compares File and symbol targets using direct/total counts only; it never waives the RISK gate +- riskScale notes unavailable process/module axes. This Graph-RAG tool expands File targets to in-file symbols; MCP File impact does not`, schema: z.object({ target: z.string().describe('Name of the function, class, or file to analyze'), direction: z diff --git a/gitnexus-web/src/core/llm/types.ts b/gitnexus-web/src/core/llm/types.ts index b7727da10..c5198bd16 100644 --- a/gitnexus-web/src/core/llm/types.ts +++ b/gitnexus-web/src/core/llm/types.ts @@ -20,6 +20,71 @@ export type LLMProvider = | 'glm' | 'deepseek'; +export const MINIMAX_ANTHROPIC_BASE_URLS = { + global_en: 'https://api.minimax.io/anthropic', + cn_zh: 'https://api.minimaxi.com/anthropic', +} as const; + +export const MINIMAX_DOCS_ROOTS = { + global_en: 'https://platform.minimax.io/docs', + cn_zh: 'https://platform.minimaxi.com/docs', +} as const; + +export const MINIMAX_MODEL_IDS = ['MiniMax-M3', 'MiniMax-M2.7'] as const; + +export type MiniMaxModelId = (typeof MINIMAX_MODEL_IDS)[number]; +export type MiniMaxThinkingMode = 'adaptive' | 'disabled' | 'always_on'; +export type MiniMaxInputModality = 'text' | 'image' | 'video'; + +export interface MiniMaxModelCapabilities { + contextWindow: number; + inputModalities: readonly MiniMaxInputModality[]; + thinkingModes: readonly MiniMaxThinkingMode[]; +} + +export const MINIMAX_MODEL_CAPABILITIES: Record = { + 'MiniMax-M3': { + contextWindow: 1_000_000, + inputModalities: ['text', 'image', 'video'], + thinkingModes: ['adaptive', 'disabled'], + }, + 'MiniMax-M2.7': { + contextWindow: 204_800, + inputModalities: ['text'], + thinkingModes: ['always_on'], + }, +}; + +export const getMiniMaxModelCapabilities = (model: string): MiniMaxModelCapabilities | undefined => + MINIMAX_MODEL_CAPABILITIES[model as MiniMaxModelId]; + +export type MiniMaxMediaDetail = 'low' | 'default' | 'high'; + +export type MiniMaxMediaSource = + | { + type: 'url'; + url: string; + detail?: MiniMaxMediaDetail; + fps?: number; + max_long_side_pixel?: number; + } + | { + type: 'base64'; + media_type: string; + data: string; + detail?: MiniMaxMediaDetail; + fps?: number; + max_long_side_pixel?: number; + }; + +export type AgentUserContent = + | string + | Array< + | { type: 'text'; text: string } + | { type: 'image'; source: MiniMaxMediaSource } + | { type: 'video'; source: MiniMaxMediaSource } + >; + /** * Base configuration shared by all providers */ @@ -94,7 +159,9 @@ export interface OpenRouterConfig extends BaseProviderConfig { export interface MiniMaxConfig extends BaseProviderConfig { provider: 'minimax'; apiKey: string; - model: string; // e.g., 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed' + model: string; + baseUrl?: string; + thinkingMode?: MiniMaxThinkingMode; } /** @@ -200,7 +267,9 @@ export const DEFAULT_LLM_SETTINGS: LLMSettings = { }, minimax: { apiKey: '', - model: 'MiniMax-M2.5', + model: MINIMAX_MODEL_IDS[0], + baseUrl: MINIMAX_ANTHROPIC_BASE_URLS.global_en, + thinkingMode: 'adaptive', temperature: 0.1, }, glm: { diff --git a/gitnexus-web/src/hooks/useAppState.tsx b/gitnexus-web/src/hooks/useAppState.tsx index d698b87d5..deeda86bf 100644 --- a/gitnexus-web/src/hooks/useAppState.tsx +++ b/gitnexus-web/src/hooks/useAppState.tsx @@ -40,6 +40,7 @@ import { repoIdentity as repoIdentityOf, type BackendRepo, type ConnectResult, + type GrepOptions, type JobProgress, } from '../services/backend-client'; import { ERROR_RESET_DELAY_MS } from '../config/ui-constants'; @@ -671,7 +672,8 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => { const backend = { executeQuery, search: (query: string, opts?: any) => backendSearch(query, { ...opts, repo }), - grep: (pattern: string, limit?: number) => backendGrep(pattern, repo, limit), + grep: (pattern: string, limit?: number, opts?: GrepOptions) => + backendGrep(pattern, repo, limit, opts), readFile: (filePath: string) => backendReadFile(filePath, { repo }).then((r) => r.content), }; diff --git a/gitnexus-web/src/lib/constants.ts b/gitnexus-web/src/lib/constants.ts index 2f717cab3..5a85754cf 100644 --- a/gitnexus-web/src/lib/constants.ts +++ b/gitnexus-web/src/lib/constants.ts @@ -37,6 +37,7 @@ export const NODE_COLORS: Record = { Constructor: '#10b981', // Emerald - like Function Template: '#a78bfa', // Violet light - like Type Route: '#f43f5e', // Rose - like Process + Destination: '#fb7185', // Rose light - like Route, the broker-side counterpart Tool: '#a855f7', // Purple - like Project BasicBlock: '#475569', // Slate darker - control-flow node (muted, taint/PDG substrate) }; @@ -79,6 +80,7 @@ export const NODE_SIZES: Record = { Constructor: 4, // Like Function Template: 3, // Like Type Route: 5, // Like Enum + Destination: 5, // Like Route - the broker-side counterpart Tool: 5, // Like Enum BasicBlock: 2, // Tiny - control-flow node (taint/PDG substrate) }; diff --git a/gitnexus-web/src/lib/upload-filter.test.ts b/gitnexus-web/src/lib/upload-filter.test.ts index e97b9ec2c..1943720ba 100644 --- a/gitnexus-web/src/lib/upload-filter.test.ts +++ b/gitnexus-web/src/lib/upload-filter.test.ts @@ -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); diff --git a/gitnexus-web/src/lib/upload-filter.ts b/gitnexus-web/src/lib/upload-filter.ts index a24520f74..9b3fb30db 100644 --- a/gitnexus-web/src/lib/upload-filter.ts +++ b/gitnexus-web/src/lib/upload-filter.ts @@ -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 + // `/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', diff --git a/gitnexus-web/src/locales/en/settings.json b/gitnexus-web/src/locales/en/settings.json index cf9746c72..91c6b2a68 100644 --- a/gitnexus-web/src/locales/en/settings.json +++ b/gitnexus-web/src/locales/en/settings.json @@ -76,8 +76,20 @@ "apiKeyPlaceholder": "Enter your MiniMax API key", "helperText": "Get your API key from", "helperLinkLabel": "MiniMax Platform", - "modelPlaceholder": "e.g., MiniMax-M2.5, MiniMax-M2.5-highspeed", - "helperModel": "Available: MiniMax-M2.5 (default), MiniMax-M2.5-highspeed (faster)" + "modelPlaceholder": "e.g., MiniMax-M3 or MiniMax-M2.7", + "helperModel": "Available: MiniMax-M3 (default) and MiniMax-M2.7", + "endpoint": "Regional endpoint", + "endpoints": { + "global": "Global (api.minimax.io)", + "china": "China (api.minimaxi.com)" + }, + "thinking": "Thinking mode", + "thinkingModes": { + "adaptive": "Adaptive", + "disabled": "Disabled", + "always_on": "Always on" + }, + "capabilities": "{{contextWindow}} token context | Inputs: {{modalities}}" }, "glm": { "apiKeyPlaceholder": "Enter your Z.AI API key" diff --git a/gitnexus-web/src/locales/zh-CN/settings.json b/gitnexus-web/src/locales/zh-CN/settings.json index 0efe220d4..4bc4fb05b 100644 --- a/gitnexus-web/src/locales/zh-CN/settings.json +++ b/gitnexus-web/src/locales/zh-CN/settings.json @@ -76,8 +76,20 @@ "apiKeyPlaceholder": "输入 MiniMax API Key", "helperText": "从这里获取 API Key:", "helperLinkLabel": "MiniMax Platform", - "modelPlaceholder": "例如:MiniMax-M2.5、MiniMax-M2.5-highspeed", - "helperModel": "可用:MiniMax-M2.5(默认)、MiniMax-M2.5-highspeed(更快)" + "modelPlaceholder": "例如:MiniMax-M3 或 MiniMax-M2.7", + "helperModel": "可用:MiniMax-M3(默认)和 MiniMax-M2.7", + "endpoint": "区域端点", + "endpoints": { + "global": "全球(api.minimax.io)", + "china": "中国(api.minimaxi.com)" + }, + "thinking": "思考模式", + "thinkingModes": { + "adaptive": "自适应", + "disabled": "关闭", + "always_on": "始终开启" + }, + "capabilities": "{{contextWindow}} token 上下文 | 输入:{{modalities}}" }, "glm": { "apiKeyPlaceholder": "输入 Z.AI API Key" diff --git a/gitnexus-web/src/services/backend-client.ts b/gitnexus-web/src/services/backend-client.ts index 52f8c65c9..706b6d90e 100644 --- a/gitnexus-web/src/services/backend-client.ts +++ b/gitnexus-web/src/services/backend-client.ts @@ -64,6 +64,12 @@ export interface GrepResult { text: string; } +/** Full `/api/grep` payload — `timedOut` is true when the 5s budget cut the scan short. */ +export interface GrepResponse { + results: GrepResult[]; + timedOut: boolean; +} + export interface JobProgress { phase: string; percent: number; @@ -869,23 +875,37 @@ export const search = async ( return (body.results ?? []) as EnrichedSearchResult[]; }; -/** Grep across file contents in the indexed repo. */ +/** Options for {@link grep} beyond pattern/repo/limit. */ +export interface GrepOptions { + /** Only search files whose path contains this substring (case-insensitive). */ + fileFilter?: string | null; + /** Case-sensitive matching (default: insensitive). */ + caseSensitive?: boolean; +} + +/** Grep across file contents in the indexed repo. Regex semantics server-side. */ export const grep = async ( pattern: string, repo?: string, limit?: number, -): Promise => { + opts?: GrepOptions, +): Promise => { const params = [ `pattern=${encodeURIComponent(pattern)}`, repoParam(repo), limit ? `limit=${limit}` : '', + opts?.fileFilter ? `fileFilter=${encodeURIComponent(opts.fileFilter)}` : '', + opts?.caseSensitive ? 'caseSensitive=1' : '', ] .filter(Boolean) .join('&'); const response = await fetchWithTimeout(`${_backendUrl}/api/grep?${params}`); await assertOk(response); - const body = await response.json(); - return (body.results ?? []) as GrepResult[]; + const body = (await response.json()) as Partial; + return { + results: body.results ?? [], + timedOut: body.timedOut === true, + }; }; /** Result from reading a file, optionally with line range. */ diff --git a/gitnexus-web/test/unit/agent-abort.test.ts b/gitnexus-web/test/unit/agent-abort.test.ts index 2a8475e34..92af12a0e 100644 --- a/gitnexus-web/test/unit/agent-abort.test.ts +++ b/gitnexus-web/test/unit/agent-abort.test.ts @@ -95,3 +95,34 @@ describe('streamAgentResponse abort', () => { expect(chunks).toEqual([{ type: 'error', error: 'Cannot abort the current transaction' }]); }); }); + +describe('streamAgentResponse content blocks', () => { + const userMessage: AgentMessage[] = [{ role: 'user', content: 'hello' }]; + + it('emits thinking blocks as reasoning', async () => { + const agent = { + stream: async function* () { + yield [ + 'messages', + [ + { + _getType: () => 'ai', + content: [{ type: 'thinking', thinking: 'Reviewing the repository context.' }], + tool_calls: [], + }, + ], + ]; + }, + }; + + const chunks = []; + for await (const chunk of streamAgentResponse(agent as any, userMessage)) { + chunks.push(chunk); + } + + expect(chunks).toEqual([ + { type: 'reasoning', reasoning: 'Reviewing the repository context.' }, + { type: 'done', historyMessages: undefined }, + ]); + }); +}); diff --git a/gitnexus-web/test/unit/agent-history.test.ts b/gitnexus-web/test/unit/agent-history.test.ts index 756534b25..f672e8a9d 100644 --- a/gitnexus-web/test/unit/agent-history.test.ts +++ b/gitnexus-web/test/unit/agent-history.test.ts @@ -10,6 +10,7 @@ import { DeepSeekChatOpenAI, DeepSeekChatOpenAICompletions, } from '../../src/core/llm/deepseek-chat-model'; +import { MINIMAX_ANTHROPIC_BASE_URLS, MINIMAX_MODEL_IDS } from '../../src/core/llm/types'; describe('buildLangChainMessages', () => { it('reconstructs assistant tool-call turns for replay', () => { @@ -50,6 +51,24 @@ describe('buildLangChainMessages', () => { ]); expect((langChainMessages[2] as any).tool_call_id).toBe('call_weather'); }); + + it('preserves MiniMax image and video content blocks', () => { + const content = [ + { type: 'text' as const, text: 'Compare these inputs.' }, + { + type: 'image' as const, + source: { type: 'url' as const, url: 'https://example.com/image.png' }, + }, + { + type: 'video' as const, + source: { type: 'url' as const, url: 'https://example.com/video.mp4', fps: 1 }, + }, + ]; + + const [message] = buildLangChainMessages([{ role: 'user', content }]); + + expect((message as any).content).toEqual(content); + }); }); describe('serializeAgentHistoryMessages', () => { @@ -206,6 +225,48 @@ it('drops reasoningContent from serialized assistant messages without tool calls }); describe('createChatModel', () => { + it('configures MiniMax-M3 adaptive thinking on the China endpoint', () => { + const model = createChatModel({ + provider: 'minimax', + apiKey: 'minimax-test-key', + model: MINIMAX_MODEL_IDS[0], + baseUrl: MINIMAX_ANTHROPIC_BASE_URLS.cn_zh, + thinkingMode: 'adaptive', + temperature: 0.1, + } as any) as any; + + expect(model.model).toBe(MINIMAX_MODEL_IDS[0]); + expect(model.clientOptions.baseURL).toBe(MINIMAX_ANTHROPIC_BASE_URLS.cn_zh); + expect(model.thinking).toEqual({ type: 'adaptive' }); + expect(model.temperature).toBeUndefined(); + }); + + it('supports disabled thinking for MiniMax-M3', () => { + const model = createChatModel({ + provider: 'minimax', + apiKey: 'minimax-test-key', + model: MINIMAX_MODEL_IDS[0], + thinkingMode: 'disabled', + temperature: 0.1, + } as any) as any; + + expect(model.thinking).toEqual({ type: 'disabled' }); + expect(model.temperature).toBe(0.1); + }); + + it('keeps MiniMax-M2.7 thinking always on', () => { + const model = createChatModel({ + provider: 'minimax', + apiKey: 'minimax-test-key', + model: MINIMAX_MODEL_IDS[1], + thinkingMode: 'disabled', + temperature: 0.1, + } as any) as any; + + expect(model.invocationParams({}).thinking).toBeUndefined(); + expect(model.temperature).toBeUndefined(); + }); + it('keeps DeepSeek model subclasses on withConfig clones used for tool binding', () => { const model = createChatModel({ provider: 'deepseek', diff --git a/gitnexus-web/test/unit/agent-prompt.test.ts b/gitnexus-web/test/unit/agent-prompt.test.ts index c2cb1392f..8bf69a513 100644 --- a/gitnexus-web/test/unit/agent-prompt.test.ts +++ b/gitnexus-web/test/unit/agent-prompt.test.ts @@ -43,7 +43,7 @@ const FORBIDDEN_TOOL_NAMES = [ const stubBackend: GraphRAGBackend = { executeQuery: async () => [], search: async () => [], - grep: async () => [], + grep: async () => ({ results: [], timedOut: false }), readFile: async () => '', }; diff --git a/gitnexus-web/test/unit/backend-client-grep.test.ts b/gitnexus-web/test/unit/backend-client-grep.test.ts new file mode 100644 index 000000000..183068d5e --- /dev/null +++ b/gitnexus-web/test/unit/backend-client-grep.test.ts @@ -0,0 +1,75 @@ +/** + * `/api/grep` client: query params and `timedOut` must reach callers. + * Dropping `timedOut` made a 5s partial scan look like a complete miss. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { __resetBreakerRegistry__ } from 'gitnexus-shared/test-helpers'; +import { grep, setBackendUrl } from '../../src/services/backend-client'; + +const BASE = 'http://grep-client.test:4747'; + +const jsonOk = (body: unknown) => + new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + +describe('backend-client grep', () => { + beforeEach(() => { + __resetBreakerRegistry__(); + setBackendUrl(BASE); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('forwards fileFilter and caseSensitive and returns timedOut', async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + expect(url).toContain('/api/grep?'); + expect(url).toContain(`pattern=${encodeURIComponent('sign|Sign')}`); + expect(url).toContain(`fileFilter=${encodeURIComponent('src/api')}`); + expect(url).toContain('caseSensitive=1'); + expect(url).toContain('limit=12'); + return jsonOk({ + results: [{ filePath: 'src/api.ts', line: 3, text: 'signOrder()' }], + timedOut: true, + }); + }); + vi.stubGlobal('fetch', fetchMock); + + const body = await grep('sign|Sign', '/repo', 12, { + fileFilter: 'src/api', + caseSensitive: true, + }); + expect(body.results).toEqual([{ filePath: 'src/api.ts', line: 3, text: 'signOrder()' }]); + expect(body.timedOut).toBe(true); + }); + + it('reports timedOut false when the server completed the scan', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + return jsonOk({ results: [] }); + }), + ); + + const body = await grep('TODO'); + expect(body).toEqual({ results: [], timedOut: false }); + }); + + it('does not send fileFilter when it is null or empty', async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + expect(url).not.toContain('fileFilter='); + return jsonOk({ results: [] }); + }); + vi.stubGlobal('fetch', fetchMock); + + for (const fileFilter of ['', null] as const) { + await grep('x', undefined, undefined, { fileFilter }); + } + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/gitnexus-web/test/unit/grep-tool.test.ts b/gitnexus-web/test/unit/grep-tool.test.ts new file mode 100644 index 000000000..8701ca665 --- /dev/null +++ b/gitnexus-web/test/unit/grep-tool.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createGraphRAGTools, type GraphRAGBackend } from '../../src/core/llm/tools'; + +const noOpBackend: GraphRAGBackend = { + executeQuery: async () => [], + search: async () => [], + grep: async () => ({ results: [], timedOut: false }), + readFile: async () => '', +}; + +function grepTool(backend: GraphRAGBackend) { + return createGraphRAGTools(backend).find((candidate) => candidate.name === 'grep')!; +} + +describe('grep tool timeout contract', () => { + it('says the scan was incomplete when the server sets timedOut with no hits', async () => { + const grep = vi.fn(async () => ({ results: [], timedOut: true })); + const output = await grepTool({ ...noOpBackend, grep }).invoke({ pattern: 'signOrder' }); + expect(output).toContain('No matches for "signOrder"'); + expect(output).toContain('results may be incomplete'); + }); + + it('still warns when a timed-out scan returned some hits below the limit', async () => { + const grep = vi.fn(async () => ({ + results: [{ filePath: 'a.ts', line: 1, text: 'signOrder()' }], + timedOut: true, + })); + const output = await grepTool({ ...noOpBackend, grep }).invoke({ + pattern: 'signOrder', + maxResults: 100, + }); + expect(output).toContain('Found 1 matches'); + expect(output).toContain('results may be incomplete'); + expect(output).not.toContain('Showing first'); + }); +}); diff --git a/gitnexus-web/test/unit/impact-tool.test.ts b/gitnexus-web/test/unit/impact-tool.test.ts new file mode 100644 index 000000000..704240e6e --- /dev/null +++ b/gitnexus-web/test/unit/impact-tool.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createGraphRAGTools, type GraphRAGBackend } from '../../src/core/llm/tools'; + +const noOpBackend: GraphRAGBackend = { + executeQuery: async () => [], + search: async () => [], + grep: async () => ({ results: [], timedOut: false }), + readFile: async () => '', +}; + +function impactTool(backend: GraphRAGBackend) { + return createGraphRAGTools(backend).find((candidate) => candidate.name === 'impact')!; +} + +describe('Graph-RAG impact risk contract', () => { + it('advertises the edit gate, shared axes, and MCP File difference', () => { + const description = impactTool(noOpBackend).description; + expect(description).toContain('RISK is the edit gate'); + expect(description).toContain('Shared-axes risk'); + expect(description).toContain('riskScale'); + expect(description).toContain('MCP File impact does not'); + }); + + it('renders failed enrichment as unavailable and fails the risk gate closed', async () => { + const executeQuery = vi.fn(async (query: string) => { + if (query.includes("WHERE n.name = 'target'")) { + return [{ id: 'target-id', nodeType: 'Function', filePath: 'src/target.ts' }]; + } + if (query.includes('MATCH (affected)-[r:CodeRelation]->(target)')) { + return [ + { + id: 'caller-id', + name: 'caller', + nodeType: 'Function', + filePath: 'src/caller.ts', + startLine: 4, + edgeType: 'CALLS', + confidence: 1, + }, + ]; + } + if (query.includes('STEP_IN_PROCESS')) throw new Error('process query failed'); + if (query.includes('MEMBER_OF')) return []; + return []; + }); + + const output = await impactTool({ ...noOpBackend, executeQuery }).invoke({ + target: 'target', + direction: 'upstream', + maxDepth: 1, + }); + + expect(output).toContain('AFFECTED PROCESSES:\n- Unavailable (enrichment query failed)'); + expect(output).not.toContain('AFFECTED PROCESSES:\n- None found'); + expect(output).toContain('RISK: UNKNOWN'); + expect(output).toContain('risk is unresolved because enrichment failed'); + expect(output).toContain('- Processes affected: unavailable'); + }); + + it('preserves proved CRITICAL risk when the cluster query fails', async () => { + const executeQuery = vi.fn(async (query: string) => { + if (query.includes("WHERE n.name = 'target'")) { + return [{ id: 'target-id', nodeType: 'Function', filePath: 'src/target.ts' }]; + } + if (query.includes('MATCH (affected)-[r:CodeRelation]->(target)')) { + return [ + { + id: 'caller-id', + name: 'caller', + nodeType: 'Function', + filePath: 'src/caller.ts', + edgeType: 'CALLS', + confidence: 1, + }, + ]; + } + if (query.includes('STEP_IN_PROCESS')) { + return Array.from({ length: 5 }, (_, index) => ({ + label: `process-${index}`, + hits: 1, + minStep: index + 1, + stepCount: 5, + })); + } + if (query.includes('MEMBER_OF') && query.includes('COUNT(DISTINCT s.id)')) { + throw new Error('cluster query failed'); + } + if (query.includes('MEMBER_OF')) return []; + return []; + }); + + const output = await impactTool({ ...noOpBackend, executeQuery }).invoke({ + target: 'target', + direction: 'upstream', + maxDepth: 1, + }); + + expect(output).toContain('RISK: CRITICAL'); + expect(output).toContain('AFFECTED CLUSTERS:\n- Unavailable (enrichment query failed)'); + expect(output).toContain('- Processes affected: 5'); + expect(output).toContain('- Clusters affected: unavailable'); + }); + + it('does not invent direct/indirect cluster classification after its query fails', async () => { + const executeQuery = vi.fn(async (query: string) => { + if (query.includes("WHERE n.name = 'target'")) { + return [{ id: 'target-id', nodeType: 'Function', filePath: 'src/target.ts' }]; + } + if (query.includes('MATCH (affected)-[r:CodeRelation]->(target)')) { + return [ + { + id: 'caller-id', + name: 'caller', + nodeType: 'Function', + filePath: 'src/caller.ts', + edgeType: 'CALLS', + confidence: 1, + }, + ]; + } + if (query.includes('STEP_IN_PROCESS')) return []; + if (query.includes('MEMBER_OF') && query.includes('RETURN DISTINCT')) { + throw new Error('classification query failed'); + } + if (query.includes('MEMBER_OF')) return [{ label: 'Core', hits: 1 }]; + return []; + }); + + const output = await impactTool({ ...noOpBackend, executeQuery }).invoke({ + target: 'target', + direction: 'upstream', + maxDepth: 1, + }); + + expect(output).toContain('- Core (classification-unavailable, 1 symbols)'); + expect(output).toContain('direct/indirect cluster classification is unavailable'); + expect(output).not.toContain('process/module axes were unused'); + }); + + it('treats successful File expansion as comparable because enrichment runs on member symbols', async () => { + const executeQuery = vi.fn(async (query: string) => { + if (query.includes("n.filePath CONTAINS 'src/target.ts'")) { + return [{ id: 'file-id', nodeType: 'File', filePath: 'src/target.ts' }]; + } + if (query.includes("callee.filePath = 'src/target.ts'")) { + return [ + { + id: 'caller-id', + name: 'caller', + nodeType: 'Function', + filePath: 'src/caller.ts', + edgeType: 'CALLS', + confidence: 1, + }, + ]; + } + if (query.includes('STEP_IN_PROCESS')) { + return [{ label: 'Build', hits: 1, minStep: 1, stepCount: 1 }]; + } + if (query.includes('MEMBER_OF') && query.includes('RETURN DISTINCT')) { + return [{ label: 'Core' }]; + } + if (query.includes('MEMBER_OF')) return [{ label: 'Core', hits: 1 }]; + return []; + }); + + const output = await impactTool({ ...noOpBackend, executeQuery }).invoke({ + target: 'src/target.ts', + direction: 'upstream', + maxDepth: 1, + }); + + expect(output).toContain('process/cluster axes are comparable here when enrichment succeeds'); + expect(output).toContain('- Processes affected: 1'); + expect(output).toContain('- Clusters affected: 1'); + expect(output).not.toContain('process/module axes were unused'); + }); + + it('surfaces the 500-symbol enrichment cap as partial', async () => { + const executeQuery = vi.fn(async (query: string) => { + if (query.includes("WHERE n.name = 'target'")) { + return [{ id: 'target-id', nodeType: 'Function', filePath: 'src/target.ts' }]; + } + const depth = query.includes('3 AS depth') ? 3 : query.includes('2 AS depth') ? 2 : 1; + if (query.includes('CodeRelation') && query.includes(` ${depth} AS depth`)) { + return Array.from({ length: 200 }, (_, index) => ({ + id: `d${depth}-${index}`, + name: `node-${depth}-${index}`, + nodeType: 'Function', + filePath: `src/d${depth}-${index}.ts`, + edgeType: 'CALLS', + confidence: 1, + })); + } + if (query.includes('STEP_IN_PROCESS') || query.includes('MEMBER_OF')) return []; + return []; + }); + + const output = await impactTool({ ...noOpBackend, executeQuery }).invoke({ + target: 'target', + direction: 'upstream', + maxDepth: 3, + }); + + expect(output).toContain('process/cluster enrichment is partial (first 500 symbols)'); + expect(output).toContain('enrichment-truncated'); + expect(output).not.toContain('enrichment-budget-exhausted'); + }); +}); diff --git a/gitnexus-web/test/unit/settings-service.test.ts b/gitnexus-web/test/unit/settings-service.test.ts index a9ded356f..b0762604f 100644 --- a/gitnexus-web/test/unit/settings-service.test.ts +++ b/gitnexus-web/test/unit/settings-service.test.ts @@ -10,6 +10,12 @@ import { getAvailableModels, getProviderCapabilities, } from '../../src/core/llm/settings-service'; +import { + getMiniMaxModelCapabilities, + MINIMAX_ANTHROPIC_BASE_URLS, + MINIMAX_MODEL_IDS, +} from '../../src/core/llm/types'; +import { createChatModel } from '../../src/core/llm/agent'; describe('loadSettings', () => { it('returns defaults when nothing is stored', () => { @@ -17,6 +23,11 @@ describe('loadSettings', () => { expect(settings.activeProvider).toBeDefined(); expect(settings.openai).toBeDefined(); expect(settings.ollama).toBeDefined(); + expect(settings.minimax).toMatchObject({ + model: MINIMAX_MODEL_IDS[0], + baseUrl: MINIMAX_ANTHROPIC_BASE_URLS.global_en, + thinkingMode: 'adaptive', + }); }); it('merges stored values with defaults', () => { @@ -35,6 +46,30 @@ describe('loadSettings', () => { expect(settings.openai).toBeDefined(); }); + it('migrates unsupported legacy MiniMax models to the current default', () => { + sessionStorage.setItem( + 'gitnexus-llm-settings', + JSON.stringify({ + activeProvider: 'minimax', + minimax: { + apiKey: 'minimax-test-key', + model: 'MiniMax-M2.5', + temperature: 0.1, + }, + }), + ); + + const settings = loadSettings(); + expect(settings.minimax).toMatchObject({ + model: MINIMAX_MODEL_IDS[0], + thinkingMode: 'adaptive', + }); + + const model = createChatModel(getActiveProviderConfig()!) as any; + expect(model.model).toBe(MINIMAX_MODEL_IDS[0]); + expect(model.thinking).toEqual({ type: 'adaptive' }); + }); + it('returns defaults on corrupted JSON', () => { sessionStorage.setItem('gitnexus-llm-settings', 'not-json{{{'); const settings = loadSettings(); @@ -116,6 +151,26 @@ describe('getActiveProviderConfig', () => { expect(config!.provider).toBe('deepseek'); }); + it('returns the regional endpoint and thinking mode for MiniMax', () => { + const settings = loadSettings(); + settings.activeProvider = 'minimax'; + settings.minimax = { + ...settings.minimax, + apiKey: 'minimax-test-key', + model: MINIMAX_MODEL_IDS[0], + baseUrl: MINIMAX_ANTHROPIC_BASE_URLS.cn_zh, + thinkingMode: 'disabled', + }; + saveSettings(settings); + + expect(getActiveProviderConfig()).toMatchObject({ + provider: 'minimax', + model: MINIMAX_MODEL_IDS[0], + baseUrl: MINIMAX_ANTHROPIC_BASE_URLS.cn_zh, + thinkingMode: 'disabled', + }); + }); + it('returns null for openrouter with empty API key', () => { const settings = loadSettings(); settings.activeProvider = 'openrouter'; @@ -161,6 +216,20 @@ describe('getAvailableModels', () => { expect(getAvailableModels('ollama').length).toBeGreaterThan(0); expect(getAvailableModels('anthropic')).toContain('claude-sonnet-4-20250514'); expect(getAvailableModels('deepseek')).toContain('deepseek-v4-flash'); + expect(getAvailableModels('minimax')).toEqual([...MINIMAX_MODEL_IDS]); + }); + + it('describes MiniMax model input and thinking capabilities', () => { + expect(getMiniMaxModelCapabilities(MINIMAX_MODEL_IDS[0])).toEqual({ + contextWindow: 1_000_000, + inputModalities: ['text', 'image', 'video'], + thinkingModes: ['adaptive', 'disabled'], + }); + expect(getMiniMaxModelCapabilities(MINIMAX_MODEL_IDS[1])).toEqual({ + contextWindow: 204_800, + inputModalities: ['text'], + thinkingModes: ['always_on'], + }); }); it('returns empty array for unknown provider', () => { diff --git a/gitnexus/CHANGELOG.md b/gitnexus/CHANGELOG.md index def77dce8..6b4520d63 100644 --- a/gitnexus/CHANGELOG.md +++ b/gitnexus/CHANGELOG.md @@ -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` 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 diff --git a/gitnexus/README.md b/gitnexus/README.md index e9b16ac73..9abc122c5 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -204,7 +204,7 @@ Your AI agent gets **17 tools** (15 per-repo + 2 group) automatically: | `group_list` | List configured repository groups | | `group_sync` | Rebuild a group's Contract Registry and cross-repo links | -> With one indexed repo, the `repo` param is optional. With multiple, specify which: `query({search_query: "auth", repo: "my-app"})`. Per-repo tools also take an optional `branch` for indexes pinned with `gitnexus analyze --branch`; omitting it queries the workspace index, which follows your checked-out working tree. `explain` and `pdg_query` need an index built with `gitnexus analyze --pdg`. +> Read-only tools can omit `repo` when one repo is indexed, an MCP default is configured, or the GitNexus process cwd is inside a registered path without crossing into an unindexed nested Git checkout. Otherwise—and for mutating tools with multiple indexed repos and no MCP default—specify it explicitly: `query({search_query: "auth", repo: "my-app"})`. Per-repo tools also take an optional `branch` for indexes pinned with `gitnexus analyze --branch`; omitting it queries the workspace index, which follows your checked-out working tree. `explain` and `pdg_query` need an index built with `gitnexus analyze --pdg`. ## MCP Resources @@ -234,19 +234,22 @@ Your AI agent gets **17 tools** (15 per-repo + 2 group) automatically: gitnexus setup # Configure MCP for detected editors (one-time; use -c to select) gitnexus uninstall # Preview removal of GitNexus MCP/skills/hooks (add --force to apply) gitnexus analyze [path] # Index a repository (or update stale index) +gitnexus analyze [path] --watch # Watch local files and serialize incremental refreshes gitnexus analyze --repair-fts # Fast path: rebuild/verify only FTS indexes on existing index data gitnexus analyze --force # Full rebuild: re-parse + graph rebuild + FTS rebuild gitnexus analyze --embeddings # Enable embedding generation (slower, better search) gitnexus embeddings install # Fetch the optional local embedding stack on demand (--cuda, --force) gitnexus analyze --skills # Generate repo-specific skill files from detected communities -gitnexus analyze --skip-agents-md # Preserve custom AGENTS.md/CLAUDE.md gitnexus section edits +gitnexus analyze --skip-agents-md # Preserve custom AGENTS.md/CLAUDE.md gitnexus section edits (does not skip standard skills; use --skip-skills; community --skills files are unaffected) gitnexus analyze --skip-skills # Skip installing standard .claude/skills/gitnexus-* skill files gitnexus analyze --skip-git # Index folders that are not Git repositories gitnexus analyze --workers # Parse worker pool size (>=1; default: cores-1, capped at 16) +gitnexus analyze --spring-actuator ./actuator # Enrich with local Spring Boot Actuator JSON snapshots gitnexus analyze --verbose # Log skipped files when parsers are unavailable gitnexus analyze --max-file-size 1024 # Skip files larger than N KB (default: 512, cap: 32768) gitnexus analyze --worker-timeout 60 # Increase worker idle timeout for slow parses gitnexus analyze --wal-checkpoint-threshold 67108864 # 64 MiB. Control LadybugDB WAL auto-checkpoint threshold (default: 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB) +gitnexus auto-sync [init|start|restart|stop|status|reset] # Scheduled remote clone/pull + analyze from GITNEXUS_HOME/watch_config.yml gitnexus mcp # Start MCP server (stdio) — serves all indexed repos gitnexus serve # Start local HTTP server (multi-repo) for web UI gitnexus index # Register an existing .gitnexus/ folder into the global registry @@ -256,6 +259,7 @@ gitnexus clean # Delete index for current repo gitnexus clean --all --force # Delete all indexes gitnexus wiki [path] # Generate LLM-powered docs from knowledge graph gitnexus wiki --model # Wiki with custom LLM model (default: minimax/minimax-m2.5) +gitnexus wiki --provider grok # Local Grok Build CLI (uses `grok login`, no API key) gitnexus wiki --base-url http://llama-box.local:8080/v1 --allow-insecure-connection llama-box.local # Allow an exact LAN/self-hosted HTTP LLM host; env: GITNEXUS_ALLOW_INSECURE_CONNECTION gitnexus doctor # Show runtime platform capabilities and embedding configuration @@ -281,6 +285,70 @@ gitnexus group status # Check staleness of repos in a group gitnexus group impact --target --repo # Cross-repo blast radius ``` +`gitnexus analyze --watch` requires a Git repository. It performs an initial +analysis and then debounces scanner-admitted working-tree changes for 300 ms by +default into serialized incremental refreshes. Events arriving during a run +remain queued, and retryable failures retain the same batch with bounded +backoff. Invalid `.gitnexusrc` or ignore-file reloads pause ordinary refreshes +until the control file is fixed. Watch refreshes update only the graph: they +intentionally skip AGENTS.md / CLAUDE.md injection and standard skill +installation. Run a one-shot `gitnexus analyze` when those generated files need +updating. Stop watch mode with Ctrl+C. + +Watch mode accepts `--debounce`, `--workers`, `--worker-timeout`, +`--max-file-size`, `--branch`, `--pdg`, `--name`, `--allow-duplicate-name`, and +`--verbose`. Explicit one-shot options such as `--force`, `--repair-fts`, +embedding flags, `--skills`, `--default-branch`, `--skip-agents-md`, +`--skip-skills`, `--no-stats`, `--self-commit`, `--index-only`, and `--skip-git` +are rejected. Unsupported defaults from `.gitnexusrc` are ignored with a warning. + +POSIX requests clone-first copy-and-swap publication when the live index has no +orphan sidecars. Windows and sidecar fallback runs update in place: failures +known to occur before writes are retried, while a failure that may have mutated +the live index stops the watcher. Watch mode does not pull remotes. Running MCP +and `serve` processes periodically check for a newly published index and reopen +it without a restart. MCP checks are throttled to once every five seconds, so a +tool call before the next check can briefly use the previous index. + +### `gitnexus auto-sync` + +`gitnexus auto-sync` is a different product from `gitnexus analyze --watch`. It is the explicit long-running auto-sync entrypoint that clones or pulls configured remotes. `gitnexus watch` is reserved and does not start either job: it prints this split. `GITNEXUS_HOME` defaults to `~/.gitnexus`; `gitnexus auto-sync init` creates its default `$GITNEXUS_HOME/watch_config.yml`. Bare `gitnexus auto-sync` is the same as `gitnexus auto-sync start`; `restart`, `stop`, `status`, and `reset` manage the same `GITNEXUS_HOME` instance. `reset` removes only the derived analysis state and commit snapshot; clones, indexes, and registry entries are untouched. `start` runs in the foreground, reads the configuration once at startup, runs once immediately, then repeats on `sync_interval_minutes`; restart it after changing the configuration. Watch runtime artifacts live under `$GITNEXUS_HOME/watch/`: `project_commit_info.txt` is the human-readable per-loop snapshot, `auto-sync-state.json` is the machine state used for commit skipping and analyze failure thresholds, `watch.mutex` prevents multiple auto-sync processes for one home, `watch.owner.json` records ownership metadata, `watch.pid` plus `watch.status.json` expose process state, `watch.stop..json` is a temporary owner-fenced stop request, and `quarantine/` stores partial clone output before entries are removed after 14 days, keeping at most the five newest entries per repository regardless of age. Mutexes with verified dead owners are reclaimed automatically after an abnormal exit. Invalid or legacy mutexes fail closed; confirm no auto-sync process is running before manually removing `watch.mutex` and stale `watch.pid` / `watch.owner.json`. + +```yaml +sync_interval_minutes: 10 +max_concurrency: 1 +repo_git_timeout: 10s +analyze_timeout: 5m +analyze_failure_threshold: 3 +projects: + - local_path: /abs/path/to/repos + branches: [master, main] + overwrite_local_changes: false + remote_urls: + - git@github.com:owner/repo.git + - git@gitlab.com:group/repo.git + - git@gitee.com:owner/repo.git +``` + +`sync_interval_minutes` must be an integer of at least `5`. `local_path` must be an absolute path without traversal; each remote is cloned below it as `host/namespace/repo`, preventing same-basename repositories from colliding. `remote_urls` must use SSH SCP form for github.com, gitlab.com, or gitee.com. `repo_git_timeout` applies to each repo clone/pull and defaults to `10s`; a bare number such as `10` is interpreted as seconds, while `10000ms`, `10s`, and `1m` keep their explicit units. It must not exceed one hour or `sync_interval_minutes`, whichever is smaller — so a bare `600000` is rejected, because it means 600000 seconds rather than milliseconds. `analyze_timeout` applies to each isolated analysis worker, defaults to half of `sync_interval_minutes`, and cannot exceed that value; this keeps it within Node's timer range. Timeout and `auto-sync stop` request safe cancellation; a worker already in native work exits after it returns to a JS-visible safe point. While waiting, auto-sync reports `cancelling` or `stopping` and keeps its ownership files so another auto-sync cannot take over. The parent waits up to 5 seconds for the worker to exit; after that it stops waiting, releases its ownership files, and leaves the worker to finish and exit on its own rather than killing it mid-write. `auto-sync stop` uses this same control path on macOS and Windows. `overwrite_local_changes` defaults to `false`; a dirty local clone is skipped with an error log, while `true` allows branch fallback to replace local changes and additionally discards untracked files and directories in the clone after checkout — ignored paths, including GitNexus's own `.gitnexus/` storage, are preserved. `max_concurrency` defaults to `1` and is capped at runtime by `floor(availableMemoryGB / 2)` with a minimum of `1`; the effective value is printed at the start of each loop. Each analysis worker's heap cap is the machine-wide cap divided by the number of repositories analyzed in parallel, so concurrent workers share one memory budget instead of each claiming the whole machine. `analyze_failure_threshold` defaults to `3`, must be at least `2`, and pauses repeated failures only for the same repo branch and commit; a new commit or `gitnexus auto-sync reset` clears the block and allows analysis again. Repositories are registered and added to groups by their full remote identity (`host/namespace/repo`), so repositories with the same basename remain distinct. Use `branches` to try branches in order; legacy `branch` remains supported, but the two fields cannot be set together. If all branches are unavailable or time out, watch logs an error, records the repo status, and skips that repo for the loop. Leave `group_name` empty or omit it to skip group add/sync for that project; otherwise create the group first with `gitnexus group create `. `$GITNEXUS_HOME/watch/project_commit_info.txt` is for inspection only; GitNexus stores machine state separately in `$GITNEXUS_HOME/watch/auto-sync-state.json`. + +GraphQL contract matching is opt-in in the group's `group.yaml`: + +```yaml +detect: + graphql: true +``` + +The initial exact-only slice matches methods and properties on top-level NestJS `@Resolver` +classes using imported `@Query`, `@Mutation`, and `@Subscription` decorators. Named +`.graphql`/`.gql` operations are anchored by generated `Document` declarations; +object, static `gql` template, and `TypedDocumentString` initializers must prove the operation name +and root fields. Dynamic decorator names, anonymous operations, and ambiguous or missing graph +anchors are deliberately omitted. Add common infrastructure fields such as `/health` to +`matching.exclude_links_paths` to keep those GraphQL contracts visible without cross-linking them. + +`--spring-actuator` is explicitly opt-in. The path may be a JSON bundle keyed by `mappings`, `beans`, `conditions`, `configprops`, and/or `env`, or a directory containing endpoint-named JSON files. Runtime mappings and beans confirm matching static nodes; conditions and configuration property keys enrich existing evidence, with conservative runtime-only nodes added when no match exists. The configured input is excluded from source scanning; only normalized repository-relative exclusions are retained for future scans, never absolute paths. Env/configprops values, origins, condition messages, and source names are never persisted or printed. Enabled runs always rebuild because runtime snapshots are external to git freshness; omitting the option later rebuilds once to remove runtime evidence. Project config can set the same path with `springActuator` in `.gitnexusrc`. + > **`gitnexus uninstall`** reverses `gitnexus setup` — it removes the GitNexus MCP entries, hooks, and skill directories it added to each detected editor. Skill directories are identified **by bundled gitnexus skill name** (e.g. `gitnexus-cli/`), so if you customized files inside an installed skill directory, back them up first. It is a dry-run preview by default and prints the exact paths it would remove; pass `--force` to apply. Per-repo indexes (`gitnexus clean --all`) and the global npm package (`npm uninstall -g gitnexus`) are left for you to remove. ## Remote Embeddings @@ -389,7 +457,7 @@ Installed automatically by both `gitnexus analyze` (per-repo) and `gitnexus setu LadybugDB native binary ships as a prebuild against that floor, so on an older host it cannot load and reinstalling does not help — see [Linux: `GLIBC_2.34' not found`](#linux-glibc_234-not-found). -- **Windows, for full-text search:** the Microsoft Visual C++ 2015-2022 Redistributable (x64) *and* +- **Windows, for full-text search:** the Microsoft Visual C++ 2015-2022 Redistributable (x64) _and_ OpenSSL 3 (`libssl-3-x64.dll`, `libcrypto-3-x64.dll`) resolvable on `PATH` — see [Windows: full-text search unavailable](#windows-full-text-search-unavailable). @@ -666,17 +734,17 @@ For repositories with very large source files, `GITNEXUS_WORKER_SUB_BATCH_MAX_BY Four env vars expose the pool's resilience layers (respawn budget, cumulative-timeout cap, circuit breaker, startup handshake). Defaults are tuned for typical repos; bump them when an analyze legitimately needs more retries, or lower them to fail-fast on a known-bad shape. -| Variable | Default | Effect | -| ----------------------------------------------- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT` | `3` | Max replacement spawns per slot before the slot is dropped from the active rotation. | -| `GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS` | `5 × subBatchTimeoutMs` | Total retry wall-time budget per job before quarantining. Bounds exponentially-growing retry waits. | -| `GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD` | `max(3, poolSize)` | Per-slot consecutive deaths before the pool's circuit breaker trips. After tripping, dispatches require a fresh pool. | -| `GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS` | `30000` | Max wait at pool shutdown for a retired worker still inside native code — terminated at its next JS-safe point instead of mid-native-call, which would abort the process (`Napi::Error`, #2432). | -| `GITNEXUS_WORKER_READY_TIMEOUT_MS` | `5000` | Startup budget for a parse worker to load its grammar bindings and report `{type:'ready'}`. Slots that miss it are treated as startup crashes. Raise it on a slow or heavily loaded host where a full pool cold-starting concurrently needs more than 5s. | -| `GITNEXUS_MEMORY` | `off` | unset (autopilot on) | `off` declines GitNexus's memory autopilot: analyze will neither re-run itself with a RAM-aware heap cap nor abort the parse before V8 enters its ineffective-mark-compact death spiral. Use it when you want to drive memory manually; to simply pin a heap size, pass Node's own `--max-old-space-size`, which is already honoured as your decision. | -| `GITNEXUS_WORKER_HEAP_MB` | `clamp(512, RAM/2/poolSize, 4096)` | Per-worker V8 old-generation heap cap (#2649). Bounds pool RSS on large repos; a worker exceeding it dies with a real heap error handled by quarantine/respawn. | -| `GITNEXUS_SERVER_ANALYZE_HEAP_MB` | `min(8192, auto cap)` | Heap for the web/MCP server's forked analyze worker (#2649). Defaults to the historical 8192 MB bounded by the machine/container's RAM-aware auto cap; set an absolute MB value to override. | -| `GITNEXUS_CPP_CAPTURE_BUDGET_MS` | `20000` | Per-file wall-clock budget for C++ capture extraction; on breach the file keeps partial captures with a warning (#2432). `0` expires immediately. | +| Variable | Default | Effect | +| ----------------------------------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT` | `3` | Max replacement spawns per slot before the slot is dropped from the active rotation. | +| `GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS` | `5 × subBatchTimeoutMs` | Total retry wall-time budget per job before quarantining. Bounds exponentially-growing retry waits. | +| `GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD` | `max(3, poolSize)` | Per-slot consecutive deaths before the pool's circuit breaker trips. After tripping, dispatches require a fresh pool. | +| `GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS` | `30000` | Max wait at pool shutdown for a retired worker still inside native code — terminated at its next JS-safe point instead of mid-native-call, which would abort the process (`Napi::Error`, #2432). | +| `GITNEXUS_WORKER_READY_TIMEOUT_MS` | `5000` | Startup budget for a parse worker to load its grammar bindings and report `{type:'ready'}`. Slots that miss it are treated as startup crashes. Raise it on a slow or heavily loaded host where a full pool cold-starting concurrently needs more than 5s. | +| `GITNEXUS_MEMORY` | `off` | unset (autopilot on) | `off` declines GitNexus's memory autopilot: analyze will neither re-run itself with a RAM-aware heap cap nor abort the parse before V8 enters its ineffective-mark-compact death spiral. Use it when you want to drive memory manually; to simply pin a heap size, pass Node's own `--max-old-space-size`, which is already honoured as your decision. | +| `GITNEXUS_WORKER_HEAP_MB` | `clamp(512, RAM/2/poolSize, 4096)` | Per-worker V8 old-generation heap cap (#2649). Bounds pool RSS on large repos; a worker exceeding it dies with a real heap error handled by quarantine/respawn. | +| `GITNEXUS_SERVER_ANALYZE_HEAP_MB` | `min(8192, auto cap)` | Heap for the web/MCP server's forked analyze worker (#2649). Defaults to the historical 8192 MB bounded by the machine/container's RAM-aware auto cap; set an absolute MB value to override. | +| `GITNEXUS_CPP_CAPTURE_BUDGET_MS` | `20000` | Per-file wall-clock budget for C++ capture extraction; on breach the file keeps partial captures with a warning (#2432). `0` expires immediately. | ### Graph cleanup tuning @@ -690,8 +758,8 @@ Programmatic callers can pass `keepLocalValueSymbols: true` in `PipelineOptions` ### Scope-resolution property-key dispatch cap -During scope resolution GitNexus synthesizes CALLS edges through *property-key -dispatch* — call sites like `hooks.emitScopeCaptures()` where a property key is +During scope resolution GitNexus synthesizes CALLS edges through _property-key +dispatch_ — call sites like `hooks.emitScopeCaptures()` where a property key is registered by multiple definitions across the codebase. To keep this fan-in bounded, each property key is capped at **32 registrations**: a key registered by more than 32 distinct functions is skipped entirely (no CALLS are synthesized @@ -699,8 +767,8 @@ through it), and the dropped key names are surfaced in the analyze log for operator visibility. The cap is calibrated at 2× this repo's own provider table (16 legitimate registrations, one per language provider). -| Variable | Default | Effect | -| --------------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Variable | Default | Effect | +| --------------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GITNEXUS_MAX_PROPERTY_DISPATCH_FANOUT` | `32` | Per-property-key registration cap in the property-dispatch scope-resolution pass. Set to a positive integer to raise it for repositories whose provider/hook tables exceed the default and lose CALLS coverage on a legitimate key; non-integer or `< 1` values fall back to `32`. Lowering it tightens the overflow budget. | ```bash @@ -713,11 +781,11 @@ npx gitnexus analyze --force ### Scope-resolution dispatch-target cap -During scope resolution GitNexus resolves calls that flow through *callable -values* — function/method references bound to variables, passed as arguments, +During scope resolution GitNexus resolves calls that flow through _callable +values_ — function/method references bound to variables, passed as arguments, or stored in maps/tables. To keep that inclusion-based resolution finite, each callable site is capped at **32 dispatch targets**. When a site gathers more -candidates than the cap it is treated as **overflowed** and *all* of its call +candidates than the cap it is treated as **overflowed** and _all_ of its call edges are dropped — a cliff, not a tail, so a repository with a legitimately wide dispatch table (a single callable site resolving to 33+ targets) loses that site's whole call chain. In that case `analyze` logs @@ -727,8 +795,8 @@ candidate count, and the cap (32). Raise the cap for such repositories: -| Variable | Default | Effect | -| ------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Variable | Default | Effect | +| ------------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GITNEXUS_MAX_CALLABLE_VALUE_TARGETS` | `32` | Per-callable-site dispatch-target cap in the callable-value-flow scope-resolution pass. Set to a positive integer to raise it for repositories whose wide dispatch tables overflow the default and lose a whole call chain; non-integer or `< 1` values fall back to `32`. Lowering it tightens the overflow budget. | ```bash diff --git a/gitnexus/bench/cross-repo-trace/verify.mjs b/gitnexus/bench/cross-repo-trace/verify.mjs index 8497af38d..e965bfc07 100644 --- a/gitnexus/bench/cross-repo-trace/verify.mjs +++ b/gitnexus/bench/cross-repo-trace/verify.mjs @@ -58,9 +58,6 @@ packages: {} detect: http: true matching: - bm25_threshold: 0.7 - embedding_threshold: 0.65 - max_candidates_per_step: 3 `; } diff --git a/gitnexus/bench/emit-persistence/baselines.json b/gitnexus/bench/emit-persistence/baselines.json index 1d295bd19..5b3568256 100644 --- a/gitnexus/bench/emit-persistence/baselines.json +++ b/gitnexus/bench/emit-persistence/baselines.json @@ -1,7 +1,11 @@ { - "fingerprint": "4ee15e742a9839671a900df4f57c1c91196c64256c8cab2ac445bec605a092d5", + "fingerprint": "72096279092d4f118de7e179333705c19c9aff2664f77d71a7da48cd9f73fb5a", "scaling_budget": 1.8, "max_ms_large": 1000, + "_rebaselined_destination_broker_conflict_column_removed": "The `Destination` node table lost its trailing `brokerConflict` STRING column (DESTINATION_SCHEMA in src/core/lbug/schema.ts, the COPY statement in lbug-adapter.ts, and the `destinationWriter` header plus its row cell in csv-generator.ts). The column existed only to say WHY a destination's address had been withdrawn when two brokers claimed it; the address is no longer withdrawn — a resolved `Destination` is now keyed by `(broker, address)` via `ingestion/destination-key.ts`, so two brokers on one name are two ordinary joinable nodes and there is nothing to diagnose. That makes this a header-only shrink, and it was verified as one rather than assumed: dumping every CSV this bench emits with csv-generator.ts at the merge base and again on this branch, then diffing per file by filename, byte length and sha256, shows the file SET identical at 36 CSVs on both sides, 35 of the 36 byte-IDENTICAL (same sha256, not merely same length), and the sole difference `destination.csv` shrinking 112 -> 97 bytes: `id,name,filePath,startLine,endLine,address,broker,resolution,configKey,configDefault,brokerConflict,description` -> the same list without `brokerConflict`. That file is header-only on both sides — the synthetic benchmark graph contains no Destination nodes — so no row moved, was re-routed to another pair file, or reordered, which is the class of change this fingerprint exists to catch. Prior 4b339233662b0eebb236738abad8f7c039b9930bc1222dcad7b814afa6332fbf -> 72096279092d4f118de7e179333705c19c9aff2664f77d71a7da48cd9f73fb5a, reproduced identically across two consecutive runs. Both timing gates passed while the guard was red (scaling_ratio 0.818 and 0.751 across those two runs against the 1.8 budget; elapsed_ms_large 65.64ms and 59.32ms against the 1000ms backstop), so no throughput claim is being rebaselined away.", + "_rebaselined_3132_destination_node_table": "A new `Destination` node table (async messaging overlay; see DESTINATION_SCHEMA in src/core/lbug/schema.ts) means `streamAllCSVsToDisk` writes one more FILE, not one more column — the first rebaseline here that changes the file SET rather than a header. That makes the usual evidence more important, not less, so it was gathered the same way: dump every CSV this bench emits on the merge base and on this branch, then diff per-file by filename, byte length and sha256. Result: 35 files -> 36, the sole addition is `destination.csv`, and ALL 35 pre-existing files are byte-IDENTICAL — not merely same-length, same sha256. So nothing was re-routed into the new file and nothing reordered, which is exactly what this fingerprint exists to catch. `destination.csv` is 112 bytes, header only: the synthetic benchmark graph has no Destination nodes, so no row exists to move. Prior 7b2ec01a110dcbc66868fba2c97714aaece8c3864c2eba00df68b5c027f034d2 -> 4b339233662b0eebb236738abad8f7c039b9930bc1222dcad7b814afa6332fbf, reproduced identically across two runs. Both timing gates passed while this was red (scaling_ratio 0.711 vs the 1.8 budget, elapsed_ms_large 58.88ms vs the 1000ms backstop), so no throughput claim is being rebaselined away.", "_rebaselined_2856_property_is_detail": "Third and last of the bench guards this branch left red. The Property node table gained an `isDetail` BOOLEAN column (see PROPERTY_SCHEMA in src/core/lbug/schema.ts), so `streamAllCSVsToDisk` writes one more header field and one more cell per Property row — csv-generator.ts `propertyHeader` and the `node.label === 'Property'` tail. Verified to be header-only drift rather than a change in what is emitted: dumping every CSV this bench produces on `origin/main` and on this branch and diffing per-file (filename, byte length, sha256) shows the file SET is identical at 35 CSVs on both sides, 34 of the 35 are byte-identical, and the sole difference is `property.csv` growing 68 -> 77 bytes, `id,name,filePath,startLine,endLine,content,description,declaredType` -> `...,declaredType,isDetail`. The synthetic graph has no Property nodes, so no ROW moved at all. That is the check that matters here: a row routed to the wrong pair file, or a within-file reordering, is what this fingerprint exists to catch, and neither happened. Prior 69e9182ae205183ade24c3d8ad5d7292aea677144b1cbe443dd631bc25b0cafe -> 4ee15e742a9839671a900df4f57c1c91196c64256c8cab2ac445bec605a092d5. Both timing gates passed unchanged while this was red (scaling_ratio 0.783 vs budget 1.8, elapsed_ms_large 229ms vs the 1000ms backstop), so no throughput claim is being rebaselined away.", + "_rebaselined_3040_convex_endpoint_factory": "Const and Function gained a trailing convexEndpointFactory column. A deterministic 2,400-entity emit produced the same 35 CSV files and fingerprint c4d799c5336d616955b3530ba051b7dca300d1a0e412a66741cf2f27e04c533e. Removing the new Const and Function header fields plus the new trailing empty Function cell from each of 4,800 Function rows restored the exact prior fingerprint 4ee15e742a9839671a900df4f57c1c91196c64256c8cab2ac445bec605a092d5. No file or row moved or reordered. The measured scaling ratio remained 0.826 against the 1.8 budget and elapsed_ms_large was 307.75ms against the 1000ms backstop.", + "_rebaselined_3107_route_runtime_evidence": "Route gained trailing runtimeConfirmed BOOLEAN, runtimeSource STRING, and runtimeStatus STRING columns in its schema, CSV header/rows, COPY statement, and graph API projection. The deterministic emit still produces the same 35 CSV files; the synthetic benchmark graph has no Route rows, so the only byte drift is the Route CSV header and no row moved or reordered. Prior c4d799c5336d616955b3530ba051b7dca300d1a0e412a66741cf2f27e04c533e -> 7b2ec01a110dcbc66868fba2c97714aaece8c3864c2eba00df68b5c027f034d2. While the guard was red, scaling_ratio was 1.044 against the 1.8 budget and elapsed_ms_large was 121.08ms against the 1000ms backstop.", "_note": "fingerprint = sha256 over per-file digests (filename + sha256(file bytes)), entry list sorted — binds each emitted line to its file so a row routed to the WRONG pair file changes the hash, AND catches within-file row reordering (file bytes hashed as-written). Byte-identity gate for #2203 U2/U3. NOTE: a future change that legitimately reorders emit (without changing the node/edge SET) will trip --check; regenerate then, and record WHY in a `_rebaselined_` key alongside — bench/scope-capture/baselines.json sets that convention and it is what makes a regenerated hash reviewable. scaling_budget bounds (t_large/t_small)/(LARGE/SMALL): observed ~0.95-1.05 (linear); 1.8 tolerates disk-I/O timing noise on CI while still catching an O(n^2) re-regression (~4x). max_ms_large=1000ms is a coarse absolute backstop (observed ~200ms) that catches a gross uniform slowdown the ratio gate misses; generous so CI host noise won't flake it. Regenerate via `node --import tsx bench/emit-persistence/measure.mjs`." } diff --git a/gitnexus/bench/finalize-reexport/measure.mjs b/gitnexus/bench/finalize-reexport/measure.mjs new file mode 100644 index 000000000..e0ad67109 --- /dev/null +++ b/gitnexus/bench/finalize-reexport/measure.mjs @@ -0,0 +1,246 @@ +/** + * Build-free scaling bench for `buildReexportClosures`, the re-export closure + * pass inside `finalize`. + * + * WHY THIS EXISTS. Until #2864 the closure sub-graph admitted only `reexport` + * and `wildcard` drafts, so its input was TypeScript barrel files: a handful + * of edges, shallow chains. #2864 admits `named`/`alias` drafts flagged + * `reexportsName`, which for Python is every module-level `from m import x` — + * measured ~20x more edges on the CPython stdlib, and cyclic SCCs where there + * were none. The pass went from "rarely runs" to "runs over the whole named + * import graph", and nothing measured it. + * + * The specific regression this guards is a QUADRATIC, and it has already + * happened once. `populateFileClosure` copies the inherited `via` array at + * every hop, so an unbounded chain is Theta(depth^2) in time AND retained + * memory. `MAX_REEXPORT_DEPTH = 100` bounded it until commit `fc919ad6` + * removed it — a correct call for shallow TS barrels, invisible for years, + * and wrong the moment the input class changed. `MAX_VIA_LENGTH` restores the + * bound; this bench is what notices if it goes away again. Measured at + * depth 400: 67 ms / 145 MB uncapped vs 25 ms / 40 MB capped. + * + * TWO ARMS, deliberately not one, and only one of them is a timing arm: + * + * - `max_via_len` — EXACT and deterministic. Builds a chain far deeper than + * the cap and asserts the longest emitted `transitiveVia` is exactly + * `MAX_VIA_LENGTH`. Removing the cap is directly observable as a longer + * array, so this catches it with zero flake. + * + * This started life as a `depth_ratio` timing arm and that was a BAD GATE. + * Sampled five times capped it scored 2.71-3.52, and three times uncapped + * it scored 5.87-7.65 — the ranges nearly touch, and one uncapped run came + * in UNDER the budget. A gate that passes a third of the time on a broken + * build is worse than no gate, because it is read as evidence. The + * quadratic is real, but at these depths the pass's linear work dilutes it + * enough that wall-clock cannot separate the two cleanly. The structural + * assertion can, so it is the one that gates. + * + * - `width_ms` — an absolute ceiling on a wide, shallow, realistic package + * corpus (the shape a real Python repo actually has). Structural checks + * cannot see a constant factor: reintroducing a per-lookup linear scan of + * a target's `localDefs` leaves every array length untouched while making + * every real analyze slower. This arm IS timing-sensitive — re-run on an + * idle machine before investigating. Its budget is deliberately loose; it + * is here to catch a doubling, not to police drift. + * + * Both arms feed `finalize` through INDEXED hooks. The obvious mistake is to + * reuse the unit tests' `defaultHooks`, whose `resolveImportTarget` does + * `files.some(...)` per import — that is O(imports x files) in the FIXTURE, + * and it swamps the pass under test so completely that removing the cap + * measures as no change at all. + * + * Usage: + * node --import tsx bench/finalize-reexport/measure.mjs # report + * node --import tsx bench/finalize-reexport/measure.mjs --check # CI gate + */ +import { performance } from 'node:perf_hooks'; +import { finalize } from 'gitnexus-shared'; + +/** Must equal `MAX_VIA_LENGTH` in `gitnexus-shared`'s finalize-algorithm.ts. */ +const EXPECTED_MAX_VIA = 32; +// Generous absolute ceiling — this arm exists to catch a restored O(n^2) +// scan (which more than doubles it), not to police small drift. +const WIDTH_MS_BUDGET = 1200; + +const PROBE_DEPTH = 400; + +const deriveSimple = (d) => { + const q = d.qualifiedName; + if (q === undefined || q.length === 0) return null; + const dot = q.lastIndexOf('.'); + return dot === -1 ? q : q.slice(dot + 1); +}; + +function hooksFor(files) { + const byPath = new Map(files.map((f) => [f.filePath, f])); + const byScope = new Map(files.map((f) => [f.moduleScope, f])); + return { + resolveImportTarget: (raw) => (raw !== null && byPath.has(raw) ? raw : null), + expandsWildcardTo: (scope) => { + const t = byScope.get(scope); + return t === undefined ? [] : t.localDefs.map(deriveSimple).filter((n) => n !== null); + }, + mergeBindings: (existing, incoming) => [...existing, ...incoming], + }; +} + +const mkFile = (filePath, localDefs, parsedImports) => ({ + filePath, + moduleScope: `scope:${filePath}#1:0-9999:0:Module`, + localDefs, + parsedImports, +}); +const mkDef = (qn) => ({ nodeId: `def:${qn}`, filePath: 'x', type: 'Function', qualifiedName: qn }); +const reexporting = (name, targetRaw) => ({ + kind: 'named', + localName: name, + importedName: name, + targetRaw, + reexportsName: true, +}); + +/** A `__init__.py` chain N deep, each hop republishing the same names. */ +function chainCorpus(depth, names = 20) { + const files = [ + mkFile( + 'leaf.py', + Array.from({ length: names }, (_, j) => mkDef(`leaf.fn${j}`)), + [], + ), + ]; + let prev = 'leaf.py'; + for (let d = 0; d < depth; d++) { + const p = `hop${d}.py`; + files.push( + mkFile( + p, + [], + Array.from({ length: names }, (_, j) => reexporting(`fn${j}`, prev)), + ), + ); + prev = p; + } + files.push( + mkFile( + 'app.py', + [], + Array.from({ length: names }, (_, j) => ({ + kind: 'named', + localName: `fn${j}`, + importedName: `fn${j}`, + targetRaw: prev, + })), + ), + ); + return files; +} + +/** Wide and shallow: the layout a real Python repo has. */ +function packageCorpus({ leaves, defsPerLeaf, pkgSize, consumers, importsPerConsumer }) { + const files = []; + const leafPaths = []; + for (let i = 0; i < leaves; i++) { + const p = `pkg${Math.floor(i / pkgSize)}/mod${i}.py`; + leafPaths.push(p); + files.push( + mkFile( + p, + Array.from({ length: defsPerLeaf }, (_, j) => mkDef(`mod${i}.fn${j}`)), + [], + ), + ); + } + const initPaths = []; + for (let g = 0; g < Math.ceil(leaves / pkgSize); g++) { + const p = `pkg${g}/__init__.py`; + initPaths.push(p); + const imports = []; + for (let i = g * pkgSize; i < Math.min((g + 1) * pkgSize, leaves); i++) { + for (let j = 0; j < defsPerLeaf; j++) imports.push(reexporting(`fn${j}_${i}`, leafPaths[i])); + } + files.push(mkFile(p, [], imports)); + } + for (let c = 0; c < consumers; c++) { + const imports = []; + for (let k = 0; k < importsPerConsumer; k++) { + const g = (c * 7 + k) % initPaths.length; + imports.push({ + kind: 'named', + localName: `fn0_${g * pkgSize}`, + importedName: `fn0_${g * pkgSize}`, + targetRaw: initPaths[g], + }); + } + files.push(mkFile(`app/consumer${c}.py`, [], imports)); + } + return files; +} + +function timeMedian(files, reps = 5) { + const hooks = hooksFor(files); + finalize({ files, workspaceIndex: undefined }, hooks); // warm + const times = []; + for (let r = 0; r < reps; r++) { + const t0 = performance.now(); + finalize({ files, workspaceIndex: undefined }, hooks); + times.push(performance.now() - t0); + } + times.sort((a, b) => a - b); + return times[Math.floor(times.length / 2)]; +} + +/** Longest `transitiveVia` any edge in this graph carries. */ +function maxViaLength(files) { + const out = finalize({ files, workspaceIndex: undefined }, hooksFor(files)); + let max = 0; + for (const edges of out.imports.values()) { + for (const e of edges) { + if (e.transitiveVia !== undefined) max = Math.max(max, e.transitiveVia.length); + } + } + return max; +} + +const deepChain = chainCorpus(PROBE_DEPTH); +const maxVia = maxViaLength(deepChain); +const chainMs = timeMedian(deepChain); +const widthMs = timeMedian( + packageCorpus({ + leaves: 6000, + defsPerLeaf: 8, + pkgSize: 12, + consumers: 3000, + importsPerConsumer: 15, + }), + 3, +); + +console.log(`chain depth ${PROBE_DEPTH} : ${chainMs.toFixed(1)} ms`); +console.log(`max_via_len : ${maxVia} (must equal ${EXPECTED_MAX_VIA})`); +console.log(`width_ms : ${widthMs.toFixed(1)} (budget <= ${WIDTH_MS_BUDGET})`); + +if (process.argv.includes('--check')) { + let failed = false; + if (maxVia !== EXPECTED_MAX_VIA) { + failed = true; + console.error( + `\nFAIL max_via_len: ${maxVia}, expected exactly ${EXPECTED_MAX_VIA}.\n` + + `A LARGER value means the \`via\` chain copy lost its bound — see ` + + `MAX_VIA_LENGTH in gitnexus-shared/src/scope-resolution/finalize-algorithm.ts. ` + + `Each hop copies the inherited path, so an unbounded chain is O(depth^2) ` + + `in time and retained memory (measured 67 ms / 145 MB vs 25 ms / 40 MB at ` + + `depth ${PROBE_DEPTH}).\nA SMALLER value means the cap moved; update ` + + `EXPECTED_MAX_VIA here and the two finalize-algorithm tests that pin it.`, + ); + } + if (widthMs > WIDTH_MS_BUDGET) { + failed = true; + console.error( + `\nFAIL width_ms: ${widthMs.toFixed(1)} exceeds budget ${WIDTH_MS_BUDGET}. ` + + `With max_via_len healthy this points at a per-lookup linear scan coming ` + + `back (see indexExportsByName). Re-run on an idle machine first.`, + ); + } + if (failed) process.exit(1); + console.log('\nOK — within budget.'); +} diff --git a/gitnexus/bench/import-target/baselines.json b/gitnexus/bench/import-target/baselines.json new file mode 100644 index 000000000..d8d50b015 --- /dev/null +++ b/gitnexus/bench/import-target/baselines.json @@ -0,0 +1,1022 @@ +{ + "_what": "Baselines for bench/import-target/measure.mjs cover every import-target resolver registered in SCOPE_RESOLVERS on a shared corpus, plus a configured C# arm for the branch the default call cannot reach. measure.mjs derives its arm inventory from LANG_REGISTRY and --check reconciles the registered languages in both directions. The single PHP arm supplies its production PSR-4 Composer mapping; csharp_csproj supplies csproj configuration. C and C++ also receive their production resolutionConfig header corpus. The timing, shape, fingerprint, context, and retained-heap gates therefore cover each production resolver path without splitting PHP into configured and unconfigured identities.", + "_rebaselined_2960_kotlin_declared_packages": "Kotlin now resolves only from parsed package facts and local module bindings. This deliberately changes its five fingerprints, removes path-depth sensitivity, adds the context probe, and reduces the 32000-file retained index from 40.82 MiB to 4.31 MiB. External same-name path decoys now remain unresolved.", + "_php_composer_gate_2962": "The canonical PHP arm supplies an authoritative App PSR-4 mapping. A deterministic Vendor0 suffix decoy makes deletion of the external gate change every timing fingerprint, while the heap arm separately pins a mapped miss, the rendered mapping, and the external null result. Three serial samples measured depth ratios 1.058, 1.114, and 1.158; the 1.8 budget is 1.55x the observed maximum. The mapped-miss heap reading peaked at 39607216 retained bytes at 32000 files.", + "_fingerprint_note": "Per-language sha256 over every distinct fromFile|target -> resolved target. A change here is a BEHAVIOUR change: the resolver returned a different target set, and IMPORTS/CALLS edges moved. Explain it, never re-baseline to make CI green. For the languages these PRs changed, the pre-change implementations produce these same values on this corpus at both 400 and 1600 files \u2014 that is what makes the index hoist a performance change. The tie-break-level proof lives in test/unit/scope-resolution/import-target-index-parity.test.ts (verbatim copies of the pre-change code, diffed) for Kotlin's current declared-package behavior in test/unit/kotlin-module-resolution.test.ts, and for the four resolvers added there in test/unit/scope-resolution/{php,java,cobol}-import-target-parity.test.ts and test/unit/import-resolvers/csharp-csproj-parity.test.ts, and for JavaScript in test/unit/scope-resolution/javascript-import-target-parity.test.ts (a differential over 211200 old-vs-new pairs, PR #2911). The eight languages added last have no per-language parity harness against a pre-change implementation and do NOT need one: nothing about their resolution changed, so there is no before to diff against. Their fingerprints are pure forward guards, minted from the current implementations, and their adapter-boundary index reuse is covered for every registered language at once by test/unit/scope-resolution/import-target-index-reuse.contract.test.ts. NOTE for csharp_csproj: on this corpus the #2902 indexed leg (step 3 of resolveCSharpImportInternal) is reached by 2221 of the 3200 small-arm imports but answers null for every one of them \u2014 the 979 that resolve do so at step 2 \u2014 so this fingerprint pins that legs cost and its null answers, while its positive tie-breaks (unanchored substring, iteration order) are pinned by csharp-csproj-parity.test.ts. NOTE for kotlin, go, csharp and java: twenty fingerprints across these four languages were re-baselined in #2881, the one deliberate behaviour change any language in this file has had. It landed in two steps and the second is the reason the first is not a special case: Kotlin first, then the shared package-dir-index (go, java, csharp) and the csproj namespace index once the same rule was found live there. `getKotlinFileIndex` no longer requires a file's package directory to be the FIRST occurrence of that name in its own path, so the unique arm's `d % 7` nested slice (`mod{d}/src/main/kotlin/com/example/pkg{d}/inner/pkg{d}`) now belongs to package `pkg{d}` and its wildcard imports resolve: resolved 1100 -> 1153 small and deep, 4456 -> 4681 large. The collide arm needed a CORPUS edit alongside it, not just a new number \u2014 its `d % 7` slice deliberately imported `com.example.vendor{d}`, a package that exists nowhere, purely to mirror the unique arm's nested-slice MISS, so leaving it would have left collide at 1100 against small's 1153 and broken the same-workload invariant the arm is built on (that assertion is what caught it). It now uses the same `com.example.models.*` spelling as the rest of the arm, which is why its distinct_outcomes fell (2775 -> 2744, 11087 -> 10961): one shared target instead of one per d. The record-level evidence for the resolver change \u2014 235 of 19968 records moved, 54 null -> resolved, 0 buckets losing a member \u2014 is in bench/kotlin-import-target/baselines.json `_provenance`. The kotlin heap_reading_bytes and heap_ceiling_bytes moved with it, together as `_heap_reading_note` requires: 48073096 -> 48200224 bytes_large (+127128, +0.264%), ceiling still exactly 1.5x. Small, and it is worth saying WHY it is small rather than reading the number as evidence that the change is cheap. `dirChildren` grows by one entry per component-suffix the old rule used to skip, and this arm can only see part of that: the heap corpus is built with HEAP_PAD 8, which prefixes every path with `d0/\u2026/d7/`, so no path can begin with a suffix of its own directory and the leading-segment half of the old rule is structurally invisible here. What moves the reading is the `d % 7` nested slice alone. Read +0.264% as this arm's ceiling on the effect, not as the effect. GO NEEDED A CORPUS EDIT TO BE GATED AT ALL. Its nested slice was `src/pkg{d}/internal/pkg{d}`, repeating only the LAST segment, while a Go query addresses the whole package path `src/pkg{d}` \u2014 so the directory never even ended with the query and the first-occurrence rule was never reached. Every go arm sat unchanged through the resolver fix. `uniqueDir`/`collideDir` now repeat the shape at the granularity Go actually queries (`src/pkg{d}/internal/src/pkg{d}`, `svc{d}/internal/sub/svc{d}/internal`), which is what moved go from 979 to 1153 resolved and bumped `languages.go.heap.path_segments` 13 -> 14. The general lesson: a corpus that carries a shape the QUERY cannot express does not gate that shape. CSHARP AND JAVA HIT THE SAME COLLIDE-ARM TRAP AS KOTLIN. Both collide arms sent their `d % 7` slice to a namespace that exists nowhere (`App.Src{d}.Vendor`, `com.svc{d}.vendor`) purely to MIRROR the unique arm's nested-slice miss; once that miss became a hit, collide sat at 979/1100 against small's 1153 and the same-workload assertion failed. Both now use the same spelling as the rest of their arm. HEAP: no reading here moved for the resolver change. An earlier revision of this branch re-recorded `csharp_csproj` 73703384 -> 73116520 as a -0.79% effect of the step-2 filter; review measured base and branch three times each and got the same 73.10e6 on BOTH sides \u2014 the recorded 73703384 was simply not reproducible on this box, and re-recording it would have dropped that language's derived floor by 0.8% for no reason belonging to this change. Reverted. Everything else sat within +/-0.03%. Note that `_heap_reading_note`'s claim that these readings 'reproduce to the byte across processes on one box' did NOT hold on the box this was measured on: go, dart, ruby, python, php and cpp all wandered by a few hundred to a few thousand bytes between processes with no code change touching them. Treat sub-0.05% movement as jitter, not signal. HEAP, kotlin, second movement: 48200224 -> 42802456 (-11.20%), re-recorded with its ceiling. `getKotlinFileIndex` now compacts each `dirChildren` bucket as it freezes it. `addChild` mints a bucket as `[raw]` and pushes the rest, and V8 grows a backing store by `old + old/2 + 16`, so the second child takes a 1-slot store to 17: 61144 buckets, 52.9% of their slots empty, 88 B each. Same fix and same accounting as the python `byBasename` sentence above. Note what this means for the gate: a memory WIN of this size passes every arm \u2014 it is under the ceiling and over the 0.5x floor \u2014 so it is recorded because the convention says a reading and its ceiling move together, not because anything went red. kotlin now reads 40.82 MiB. The prose in measure.mjs calling it '45.85 MiB, the second-largest reading in this file' is corrected with it \u2014 and was already wrong on the ranking before this change, since csharp_csproj (69.73) and php (47.28) both read higher; kotlin was third. A measurement written into prose is not re-taken, which is the finding `_heap_bound_note` records about this very file. One further corpus edit, made in review and MEASURED rather than assumed: kotlin's collide layout repeated only the `models` leaf (`\u2026/com/example/models/inner/models`) while a Kotlin query addresses the whole dotted path, so a full revert of the Kotlin guards left both collide fingerprints UNMOVED \u2014 the arm was blind to the rule it was re-baselined for. Deepening it to `\u2026/models/inner/com/example/models` makes the revert move both, and those two fingerprints are the only ones that changed for it. The same deepening was applied to the java and kotlin UNIQUE arms and REVERTED: it moved ten more fingerprints, grew java's heap reading 43%, and bought nothing \u2014 progressive stripping lands those queries on the same file with or without the rule, so the control still failed only on go.", + "_shape_note": "files/imports/resolved/distinct_outcomes AND the fingerprint are asserted exactly, per scale. A fingerprint alone cannot tell a legitimate resolution change from a corpus quietly shrunk below the size at which the timing arms can see anything; conversely the counts alone cannot see a defect confined to one arm, because the arms differ only in path padding and directory layout and both of those are count-neutral by design. Two cross-arm assertions close the remaining hole: the deep and collide arms must resolve exactly what small resolves (they are the same workload), and each of their fingerprints must DIFFER from small's (they are not the same corpus). Without the second, setting DEEP_PAD to 0 \u2014 which deletes the entire depth arm \u2014 moves no asserted number and prints PASS; the same is true of a collideDir that forwards to uniqueDir. THE HEAP ARM IS ASSERTED THE SAME WAY, by the same loop, and was not before: files_small, files_large, path_segments and probe decide WHAT it measures, and every one of them was reported and compared to nothing. Swapping HEAP_PROBE_TARGET.csharp_csproj for a target matching no CSPROJ_CONFIGS rootNamespace skips the whole config loop, so the getFilesInDir and getInsensitive legs never run and the arm the header calls the witness that the read pattern IS the footprint quietly becomes a two-map arm \u2014 73703384 -> 59921216 B, ratio 1.017 -> 1.011, ceiling and floor both still passing and --check still exiting 0. Setting HEAP_SMALL equal to HEAP_LARGE is the same hole from the other side: ratio goes to ~1.0 by construction and bytes_large never moves. bytes_small and bytes_large are deliberately NOT asserted for equality \u2014 heap_ceiling_bytes and the heap_reading_bytes floor bound them with ~50% either way, because heapUsed accounting moves across platforms and Node majors and an exact byte assertion would be a re-baseline per runner. THE CONTEXT ARM IS ASSERTED THE SAME WAY, by the same loop, and more strictly than either: target, with_context and without_context are exact strings with no tolerance at all, because the arm resolves one import over a three-file corpus and has no measurement noise to tolerate. A separate check requires the last two to DIFFER, for the same reason deep.fingerprint must differ from small.fingerprint \u2014 a probe on which both call shapes agree asserts one number twice. Both halves run through resolveOne, so what the arm gates is this bench threading run.ts's fifth argument, not the resolvers' behaviour.", + "_arms_note": "Five timing arms, one memory arm and one deterministic arm elsewhere, because none of them gates alone. scaling_ratio (t_large/t_small)/(1600/400) catches cost growing with FILE COUNT \u2014 the #2877-#2880, #2901, #2902 and #2908 regressions themselves; every one of those legs was Theta(files) per import, so a revert scores ~4 here by construction. depth_ratio (t_deep/t_small at a FIXED file count, ~6x the path components) catches cost growing with path DEPTH, which scaling_ratio divides out and structurally cannot see; buildSuffixIndex (C#, Ruby, PHP, Java) emits one entry per component, while Kotlin's declared-package index is depth-free while Go, Dart and COBOL, whose indexes are depth-free, sit at ~1.0. csharp's depth_budget has now been retightened twice for the same reason, and the second time it did lock the win in. It was 5 against a then-measured 3.318; #2903 made buildSuffixIndex's dirMap lazy and it became 3.5 against 2.31, with the file stating plainly that 3.5 did NOT lock that win in because a revert to an eager dirMap scores 3.318 and passes. Extending the laziness to the two SUFFIX maps drops it again, to 1.438 (java likewise 2.214 -> 1.402), because the deep arm has ~6x the path components and an O(files x depth) build of a map the no-csproj leg never reads is exactly the cost that scales with depth. Both are now 2.2, which is this file's 1.5x convention against measurements whose own peak-to-peak over 4 runs is 1.04x and 1.07x \u2014 and 2.2 DOES lock it in: an eager rebuild scores 2.3+ and fails. The other fifteen depth budgets sit at 1.37-1.75x measured and are unchanged. collide_scaling_ratio is the same measurement on a SHARED-LEAF layout (svcN/internal, SrcN/Models, com/example/model in every service, a repeated mod0.dart/mod0.rb/Mod0.cpy basename) carrying an identical file, import and resolved count: the small/large/deep arms mint one directory name per index, so every index bucket in them holds exactly ONE entry (measured: max last-segment bucket 1 and max matching directories 1 for go and csharp at 400 and 1600 files; max basename bucket 1 for dart and ruby), and bucket cardinality is the only non-constant term the new indexes have. On the shared-leaf shape go, csharp, dart and java legitimately score 2.1-3.9 because the bucket grows with the file count BY CONSTRUCTION \u2014 this is a limit on the SCOPE of the \"independent of corpus size\" claim, not a regression (the indexed code is still faster there than the pre-change full scan); their collide budgets say so honestly instead of pretending 1.8. Ruby, Kotlin, PHP and COBOL answer from keyed maps and are collision-immune, so they keep the linear 1.8 budget and that immunity is the assertion. csharp_csproj is the one arm that runs the other way: its shared leaf collapses dirsByLastSegment to the single key Models, so the slash-free sweep (see CSPROJ_CONFIGS) is CHEAPER on the collide layout than on the unique one and its expensive scale arm is large, not collide_large. Its 1.8 collide budget is therefore the linear one, and the arm that carries its real cost is the unique one. The collide arm is also the only arm that reaches filesDirectlyInPkgDir's dirCount > 1 merge (go: 388 multi-directory calls at 400 files, up to 9 directories; 1517 at 1600 files, up to 34) and the only one that reaches COBOL's copybook-over-source tier tie-break, which needs one bookname to name two files. small_ms_ceiling and collide_ms_ceiling are ABSOLUTE (~4x the measured arm), because a constant-factor regression that grows both scale arms equally passes every ratio. The five arms added here use 4.2x, the middle of the 3.7-4.6x the original five already carry; the two COBOL arms use ~5x, the multiplier dart's sub-1 ms arm has always carried, because a fixed scheduler hiccup is a larger fraction of a smaller number \u2014 measured over 8 runs they sat at 0.25-0.37 ms and 0.18-0.30 ms, and the pre-#2908 two-scans-per-COPY implementation costs ~300 ms on the same arm, so 2.0 and 1.5 still separate fixed from broken by two orders of magnitude. NOISE, measured rather than assumed: depth_ratio divides two sub-3 ms numbers (Dart's are sub-1 ms) and is by far the noisiest arm here, so it set N for the whole file. fastest() is a min-of-N estimator, so N is the knob. Over 22 --check runs on an idle box, peak-to-peak: at N=5 go ran 0.757-1.748 (2.31x) and tripped its own 1.6 budget about 1 run in 20; at N=7 (the kotlin-import-target setting) Dart still ran 0.678-2.043 (3.01x) and tripped once; at N=15 (bench/cfg, bench/schema-pairs, bench/callable-value-flow) every language collapsed to a 1.13-1.26x swing with 22/22 passing. The budgets were NOT widened; the estimator was fixed instead, which is why the headroom above is real rather than granted. N IS NOW PER LANGUAGE, and that is a refinement of the same finding rather than a retreat from it. The overshoot of min-of-K against min-of-15 is a function of the CELL's absolute duration, not of the language: replayed against two independent runs' full sample sets, the worst overshoots at K=7 land on swift.small (0.43 ms, 31.8%) and dart.collide (1.5 ms, 37.6%), while every cell at or above 10 ms overshoots by at most 6.3%. So repsFor() keeps 15 while a language's cheapest arm is under 5 ms and otherwise spends ~150 ms per cell, floored at 7 \u2014 15 for go, csharp, dart, kotlin, java, cobol, swift, rust, python, c and cpp (every language the flakiness above was ever about, cheapest arm 0.19-3.2 ms) and 7-8 for csharp_csproj, ruby, php, javascript, typescript and vue (cheapest arm 20-28 ms). Per LANGUAGE, not per cell, so all five arms of a language share one estimator and the four ratios stay comparisons of like with like. The replay passed all 85 cells on all five gates at 0.4-0.7 of budget and saved 12.8 s and 12.4 s of a 46 s run; min-of-7 also reads slightly HIGHER than min-of-15, so the ceilings get marginally more sensitive rather than less. Confirmed on 4 fresh runs with the adaptive estimator live: every small arm inside 1.12x peak-to-peak and every collide arm inside 1.07x, with the six 7-8 rep languages at 1.008-1.071 \u2014 no worse than the 11 that kept 15. The chosen N is reported per language as `reps`. heap_ceiling_bytes bounds the retained per-pass import index, the only arm here that can see memory: buildSuffixIndex emits maps at O(files x depth), the profile package-dir-index.ts cites #2649 to avoid for itself, and csharp, ruby, php and java all retained NOTHING across imports at BASE (C#'s no-csproj leg and PHP's and Java's every leg re-scanned the raw Set; Ruby rebuilt and discarded a suffix index per require). It is measured at 8000 and 32000 files at HEAP_PAD depth rather than at the timing arms' sizes, because the finding is an ABSOLUTE footprint at repository scale. THE ARM NOW READS WHAT THE LANGUAGE READS, and that change is the whole reason this file was re-baselined. Four of these arms used to call getWorkspaceFileIndex(set) directly and then read index.all.length, which asks no suffix question at all \u2014 harmless only while buildSuffixIndex built both maps eagerly. The moment they went lazy the direct call built NO map, csharp, ruby, php and java each reported 0 B at 32000 files, and 0 B is under every ceiling: --check printed PASS over four gates that had silently become ceilings over nothing, which is precisely the failure this file's own header warns about for rust and cobol. Every arm now resolves a real MISSING import through the real resolver (HEAP_PROBE_TARGET, asserted to miss), so the maps it forces are the maps production forces, and a resolver that starts asking a new question moves the number without anyone editing the bench. That makes the READ PATTERN the dominant term, and the eight numbers say so: java 34958600 B and csharp 29862200 B ask index.get and never getInsensitive; php 37579888 B asks getInsensitive and never get, plus its own first-proper-suffix map; ruby 41025360 B and javascript 26745296 B read get(s) || getInsensitive(s) and pay for both, the second DERIVED from the first; and csharp_csproj 73705944 B additionally asks getFilesInDir. csharp_csproj IS NOW GATED, reversing the earlier decision that it would be 'a ceiling on a duplicate': at +20.8% of the C# index it was one, and at 2.47x of it \u2014 same corpus, same getWorkspaceFileIndex, three maps instead of one \u2014 it is the witness that the read pattern is the footprint. The old RESIDUAL note is superseded by that number: a dirMap-sized addition is no longer +18%, and a consumer that asks all three questions blows csharp's ceiling by 1.64x rather than sliding under it. A SECOND MEASUREMENT BIAS was removed at the same time and it moved every figure here, so do not read these against the old ones as if only the read pattern changed. buildFiles mints paths with template literals, which V8 keeps as ropes; the first traversal that slices one flattens it, allocating the flat string and dropping the rope's pieces, so a build measured over an unflattened corpus reports the index MINUS that net release \u2014 11% low, uniformly. bytes_small was read over a corpus a discarded warm-up pass had already flattened and bytes_large over a fresh one, so every ratio read ~0.85-0.89 for structures that are exactly linear in the file count. measureHeap now flattens each corpus before measuring it; all eight ratios read 0.998-1.017, and the warm-up pass is gone because with the corpus flat a language's first and second reads agree to within 0.3%. python's figure rises from 7624992 to 10362976 for this reason and not because anything regressed, and then to 10543152 (+1.7%) because #2913's nestedDirNames set is retained for the pass, and then FALLS to 6360936 (-39.7%) for a reason worth knowing: byBasename holds roughly one bucket per file, and building each with `[]` followed by `push` made V8 grow the backing store to its 16-slot minimum, so every single-file bucket retained 15 empty pointer slots. Constructing the one-element buckets directly (`set(base, [entry])`) is byte-identical in contents and 3.9 MiB smaller at 32000 paths \u2014 37% of what this arm used to read was empty array slots \u2014 the ancestorsByDir memo itself is NOT in this reading, because python's probe target misses at the nested-name rejection and never reaches the walk, so this arm does not bound that memo; measured separately with a probe that does reach it, a 32000-file corpus with every file in its own 10-deep directory retains ~19 MB, which would clear this ceiling, so repointing python's heap probe at a walking spelling means re-recording the ceiling in the same change, and c is unchanged at 10018816 because its basename map does not slice paths. Its ceiling is 1.5x the measured arm, and the DIFFERENCE FROM THE 4x TIMING CONVENTION IS DELIBERATE \u2014 do not harmonise it back. 4x exists because runner contention dominates a wall-clock number; this one has essentially no measurement noise (across 4 runs the widest spread was 0.11% on python, 0.03% on csharp_csproj and 0.00% \u2014 identical to the byte \u2014 on ruby, php, java, javascript and c, and the same holds across separate processes), so 4x would throw away almost all of the gate's power and sail straight past the regression this arm exists to catch. 1.5x still tolerates ~50% of cross-platform and Node-version drift, far more than a Node major bump plausibly moves heapUsed accounting; it catches a duplicated index (+100%) or a second exactMap-sized suffix map (+~85%). heap_floor_fraction is the arm the 0 B incident proved was missing. A ceiling can only say 'not too big'; nothing said 'still measuring something', which is why four dead arms passed. The floor is 0.5 x each language's RECORDED READING (heap_reading_bytes), which is half the measured size and says so. It used to be 0.33 x the CEILING, described the same way \u2014 true only while every ceiling stayed at exactly 1.5x its reading, a convention this file states and nothing enforces, so re-tuning one ceiling upward would have loosened that language's floor by the same factor in the one direction a floor exists to watch. The two forms agree to within 0.8% for all eight today, so this is a correction of derivation, not of strength. It sits ~400x above the readings' own reproducibility and far below any collapse. A genuine 2x memory WIN trips it too, and that is intended: like a fingerprint move, it must be explained and re-baselined rather than absorbed. COBOL is left out for the opposite reason: its index is two Map, O(files) with no depth term, and at 32000 files its retained delta does not clear the noise of the measurement itself. heap_ratio_budget, the linear-growth check across the 4x file-count gap, is the orthogonal arm: it sees per-file and per-depth growth but not a constant factor. ---- THE EIGHT LANGUAGES ADDED LAST (swift, rust, python, javascript, typescript, vue, c, cpp) ---- They carry the SAME five arms and the same gates; what differs is which arm can actually fail for each, because each resolver has a different cost axis, and the budgets below say so instead of copying a number across. Every figure quoted is the MAXIMUM over 5 full runs on an idle box, and the peak-to-peak of every one of these arms stayed inside 1.10x over those runs \u2014 tighter than the 1.13-1.26x the original nine record, because none of these arms divides two sub-1 ms numbers the way dart depth_ratio does. depth_budget is ~1.5x measured throughout: swift 2.3 (1.487), rust 2.1 (1.377), javascript 2.1 (1.376), typescript 2.1 (1.381), vue 2.3 (1.563), c 3.0 (1.990), cpp 3.0 (1.999). PYTHON WAS 11 AGAINST 7.389 AND IS NOW 2.6 AGAINST 1.872, because #2913 fixed the resolver rather than the budget. Its INDEX was always depth-free; hasRepoCandidate and resolveAbsoluteFromFiles each rebuilt one ancestor prefix per directory component of the importer on EVERY import, and the index's own dirPrefixes build inserted one entry per component per file, so the resolver was quadratic in path depth where every other language here is linear or flat. The prefixes are a pure function of the importer's DIRECTORY, so they are now memoized per directory inside getPythonFileIndex (ancestorsByDir), the leading segment is rejected up front against a set of nested directory names, the module and package buckets are consulted before the walk rather than inside it, and the dirPrefixes build stops at the first ancestor already stored. All five fingerprints are byte-identical, so it is a hoist. The budget is 2.2, and BOTH numbers behind it were re-measured on a quiet box AFTER the context leg below started being measured, because that change moved the arm: the work it adds is depth-FLAT, so python's absolute cost more than doubled while depth_ratio FELL to 1.405-1.563 over 5 serial runs (peak-to-peak 1.11x). A budget carried over from before that change would have been slack against a smaller ratio. 2.2 is 1.41x the measured maximum, inside the 1.37-1.75x band the other fifteen sit in, and it LOCKS THE WIN IN: reverting the per-directory ancestor memo alone scores 2.524 and reverting the nested-name rejection alone scores 2.553, both measured under the current call shape, so each fails at 2.2 with 13% to spare. Do not read those two figures as the pre-#2913 cost \u2014 7.239 was that, and the gap closed because the bare-import tier stopped walking at all (see below). The other two parts of the fix are not gated by this arm and are not meant to be: reverting the bucket prune or the dirPrefixes early break lands under any budget this arm's noise supports, so they are gated deterministically instead, by the prefix-parity and package-probe arms of test/unit/scope-resolution/python/python-importer-ancestors.test.ts and python-import-target-parity.test.ts, which go red on exactly those two mutations. A timing budget catches what it can measure; the counts catch the rest. THE BARE-IMPORT TIER (`import os`, single segment, no dot) was a separate O(depth) walk in import-resolvers/python.ts that this bench cannot see at all, because every python arm here spells its imports with a dot and returns at the `pathLike.includes('/')` guard before reaching it. It ran TWICE per `from x import y` \u2014 the package probe's recursion re-ran the whole tail on identical inputs \u2014 and is now one memoized chain plus an O(1) proof-of-absence against the index's basename buckets: 12/24/72 Set probes at depth 1/4/16 became a flat 2, and 11.615 us/import at 18 path components became 0.740. Gated by probe COUNT in test/unit/scope-resolution/python/python-import-probe-count.test.ts, not here. collide_scaling_budget splits three ways. Three languages scan a bucket that grows with the corpus and get their measured value x1.5: swift 4.9 (3.279 \u2014 its bucket is the module file list it RETURNS, and its collide arm is four modules instead of dirs of them so that bucket is fileCount/4, i.e. 100 files at 400 and 400 at 1600), c 3.8 (2.535) and cpp 4.0 (2.639, the same basename bucket its suffix fallback walks). Four answer from keyed maps and keep the linear 1.8 \u2014 python 1.097, javascript 1.083, typescript 1.053, vue 1.079 \u2014 and that immunity IS the assertion, exactly as for ruby, kotlin, php and cobol. RUST IS THE ONE ARM THAT WAS REDESIGNED RATHER THAN BUDGETED. It resolves by probing candidate paths with allFilePaths.has(...) and never searches, so its cost is O(path segments) and provably flat in the file count (1.095 scaling, 1.061 collide scaling): a shared-leaf collide arm for rust would have asserted nothing, which is worse than no arm. Its collide corpus is instead a deep module tree (src/l0/l1/l2/l3/l4/mod{d}) whose targets carry ~2x the :: segments, so the arm exercises the axis that CAN grow, its 1.8 budget asserts the flatness across file counts, and collide_ms_ceiling 19 bounds the absolute cost of the long-path probe. small_ms_ceiling and collide_ms_ceiling are ~4x measured as everywhere else: rust 10/19 (2.609/4.704), python 7/8 (1.76/1.929, retightened from 12/15 against 3.044/3.771 by #2913), javascript 85/89 (21.254/22.145), typescript 85/86 (21.250/21.464), vue 81/93 (20.164/23.227), c 7/11 (1.620/2.850), cpp 7/12 (1.581/3.009). Swift takes ~5x (2 against 0.421 and 4 against 0.821) \u2014 the multiplier dart and cobol already carry, because a fixed scheduler hiccup is a larger fraction of a sub-1 ms number. ONE CAVEAT ON THE THREE ts-FAMILY MS NUMBERS, stated because nothing else in this file would reveal it: resolveTsTarget carries a per-pass resolveCache keyed currentFile::importPath, which no other resolver here has, and ~10% of this corpus is repeat pairs. Their us/import is therefore a slight underestimate of a cold resolve. It is left in rather than defeated because it is what the real pipeline does, and it is identical across all three so the arms stay comparable. HEAP for the eight: rust, swift, typescript, vue, cpp and cobol are still NOT gated, all of them measured before being left out. rust builds no index on this hook (16 B at 8000 files, 0 B at 32000); swift holds one pointer per file-times-segment and mints no strings, reading 0.98 MB at 8000 files against 0.29 MB at 32000 \u2014 a 4x larger corpus reading 3x SMALLER, which is what a measurement below its own noise floor looks like, and the same reading cobol gives (0.54 MB then 0 B); typescript and vue duplicate javascript through the same builder over the same-shaped corpus, and cpp duplicates c (10021320 against 10016960, 0.04% apart). Those four duplications are the ONLY exclusions that still rest on 'it would be a duplicate', and they are duplicates of a builder AND of a read pattern, which is the pairing csharp_csproj failed once the read pattern started to matter \u2014 if any of the four ever diverges in what it ASKS the index, it earns an arm the same way csharp_csproj just did. All eight gated arms are read the same way now (retainedPassBytes, one real import), so unlike before they are directly comparable to one another. WALL CLOCK \u2014 ~33-35 s in report mode, down from ~46 s, and ~44-45 s for --check, which is essentially UNCHANGED from ~46 s. Only report mode got faster; do not read the pair as 46 -> 42. The breakdown is worth having before anyone trims it. Timing arms: go 2.02, csharp 1.09, csharp_csproj 3.22, dart 0.41, ruby 2.90, kotlin 0.85, php 3.46, java 1.57, cobol 0.09, swift 0.46, rust 0.85, python 1.22, javascript 3.23, typescript 2.72, vue 2.89, c 0.86, cpp 0.91 (28.7 s, from 39.8 s: repsFor() accounts for all of it, and every second of it comes from the six languages whose cheapest cell is 20-28 ms); heap arms 3.43 s for SEVENTEEN languages, from 2.06 s for eight (every registered language is measured now; the nine added cost 1.37 s, of which kotlin alone is 0.57 s \u2014 see _heap_bound_note), and 2.1 s came from 3.0 s for seven when flattening retired the warm-up pass; module load 3.9 s. --check pays one import that report mode does not: the inventory arm loads pipeline/registry.ts, which drags in every registered scope resolver and its providers. Measured in isolation with the bench's own static imports already resident, that import costs 6.3-6.5 s on one box and 9.3-10.0 s on another \u2014 i.e. it consumes almost the whole repsFor win, which is why --check did not get faster. It is loaded dynamically at the point of use rather than at the top of the file, so report mode does not pay it and both modes take their measurements in the same module state. IT WAS WEIGHED AND KEPT, on the number that decides it: the benchmarks job is not CI's critical path. On the last green run of main it took 9 m 23 s against 12 m 58 s for the sharded coverage job that gates the merge, so ~4 m 40 s of slack sits above this bench and those seconds buy zero merge latency. Moving the arm to a vitest file would move the registry load ONTO the critical path, and would weaken it as well: this reconciles LANG_REGISTRY's SupportedLanguages values, which are what the five dispatcher branches key off, whereas a test that cannot import measure.mjs can only reconcile this file's arm NAMES plus a hand-written rule for de-aliasing csharp_csproj. The contract test import-target-index-reuse.contract.test.ts already covers the ADAPTER-boundary contract for every registered resolver; this arm covers a different claim, that the BENCH covers the pipeline. The ts family is still the largest single block of the timing phase (8.8 s) \u2014 its cost is suffixResolve probing ~39 extensions per path part on a miss, which is the real resolver and cannot be tuned away from the bench side. IF IT HAS TO SHRINK, drop collide and collide_large for typescript and vue and nothing else: -3.9 s, and it is the only cut that removes near-duplicate work rather than coverage, because all three run the same resolveTsTarget over the same buildSuffixIndex and javascript keeps the collide arm that covers their shared collision axis. Do NOT reach for REPS_MAX: it is 15 because depth_ratio tripped its own budget about 1 run in 20 at 5 and once at 7, and lowering it would re-open that for the eleven languages whose cheapest cell is sub-5 ms \u2014 which is where every recorded trip happened. The six languages it was safe to lower have already been lowered, per language and from a measurement, by repsFor(). ---- THE FIFTH ARGUMENT (context) AND THE TWO ARMS IT MOVED ---- resolveOne now makes run.ts's five-argument call for the two hooks that declare a fifth parameter, so php and python time the legs behind it. Nothing else moved: the other fifteen arms are handed no context and build no ParsedFile[] at all, and over five runs their five ms numbers and four ratios sit exactly where they did. Both languages' ten fingerprints, resolved counts and distinct_outcomes are IDENTICAL \u2014 the leg AGREES with the cascade on this corpus, which is the whole reason the context arm had to be added rather than leaving the fingerprint to notice. PHP now runs its sole timing, depth, collision and heap workloads with Composer's PSR-4 config. The canonical recording is small_ms 12.515, collide_ms 13.684, scaling_ratio 1.077, collide_scaling_ratio 0.994, depth_ratio 1.536 and 13407592 retained bytes. Its ceilings are 55/60 ms, 2.4 depth and 20200000 bytes, preserving normal cross-run headroom without splitting PHP into benchmark identities. PYTHON, WHOSE FIGURES ARE THE LEAST SETTLED THING IN THIS FILE AND ARE RECORDED IN TWO SNAPSHOTS BECAUSE OF IT. A named import is the only spelling that reads context.parsedFiles, and it costs up to three entries into the resolver per import (package probe, exports check, submodule probe) where the synthetic namespace spelling this arm used to pass costs one. Against the resolver as it stood when the call shape changed that read small_ms 1.76 -> 5.751 and collide_ms 1.929 -> 5.894, ~3.1x. Against the resolver a few commits later \u2014 which stopped re-running the whole tail after a null package probe, a double-probe this bench could not previously see because the namespace spelling never entered that branch \u2014 the same arms read 4.404 and 4.505. The ceilings are 18 and 19, chosen to clear BOTH: 4.09x and 4.22x of the current numbers, 3.13x and 3.22x of the higher ones, so neither state is red. Retighten toward 4x once that resolver settles. ITS DEPTH ARM WAS DILUTED AND THE BUDGET IS RETIGHTENED TO MATCH, which is the one thing here worth arguing about: the added work is depth-FLAT, so depth_ratio FALLS 1.872 -> 1.478 while the absolute cost more than doubles, and 2.6 against 1.478 would be 1.76x \u2014 far looser than the 1.39x #2913 chose deliberately to lock its own fix in. 2.1 restores that multiplier (1.42x). THE TWO MUTATION SCORES #2913 RECORDED (3.123 for reverting the per-directory memo, 2.734 for reverting the nested-name rejection) WERE TAKEN AGAINST THE OLD CALL SHAPE AND HAVE NOT BEEN RE-TAKEN. Modelled forward, with the depth-quadratic term reappearing in every resolver entry so its absolute contribution scales with the entry count, they land near 2.8 and 2.4 \u2014 both above 2.1, and the second BELOW 2.6, which is the arithmetic that decided the budget. Re-run the two mutations before trusting the lock-in claim above. python's heap reading is unchanged (10543152 recorded; 10529848-10544616 across eight runs) because its probe misses before the branch that reads parsedFiles \u2014 see _blind_spot for why no probe can reach that memo. Every figure in this section is the MAXIMUM over its snapshot's runs (five, then three), with peak-to-peak 1.031-1.058 on php and 1.019-1.081 on python, taken on a box that was NOT idle and with another change landing in python's resolver mid-measurement. Re-take them serially before merging.", + "_triage": "Every ratio and ms ceiling here is a TIMING signal \u2014 re-run on an idle machine before investigating; runner contention dominates. depth_ratio is the noisiest of them by a wide margin (it divides two sub-3 ms numbers, and Dart's are sub-1 ms): if exactly one arm fails and it is that one, suspect the machine first. N is 15 for every language whose cheapest arm is under 5 ms, rather than this bench's original 5, specifically to hold that arm's peak-to-peak swing under 1.26x \u2014 see _arms_note for the measured distributions and for why the six languages that drop to 7-8 are the ones where cell size makes it safe \u2014 so a depth_ratio failure that REPRODUCES is a real signal, not noise. Each language's chosen N is printed as `reps`; read it before blaming the estimator. The fingerprint, shape and heap arms are the opposite: deterministic (over 4 runs the heap arm's widest spread was 0.11% on python and 0.00% on java, javascript and c), a re-run never changes them, and they must never be wished away. TWO heap failures mean the arm STOPPED MEASURING rather than that memory grew, and both are deterministic: a heap floor failure says the probe no longer forces the index it used to (this is how four arms read 0 B when buildSuffixIndex went lazy, and 0 B passes every ceiling), and a `heap probe ... resolved` throw says a probe target that must MISS now hits, so the reading is a materialized answer and the legs past it were never reached. A heap BOUND failure is deterministic in the same way and means one specific thing: a language excluded from the budgeted tier has grown a structure, or started asking its index a question it did not ask when the exclusion was recorded \u2014 never a timing signal, never a re-run, and never fixed by raising the bound without saying what grew. The context arm is deterministic too, and a failure there means one specific thing rather than a range of them: run.ts's fifth argument is not reaching that resolver from this bench, or the leg behind it stopped running. Never a timing signal, never a re-run. TIGHTENED IN #2881, because the measurements they bound got faster and a budget left alone while its reading falls is a gate loosening without anyone deciding to. Each new value holds the headroom the old one expressed over the old reading, computed from `_measured` on both sides: kotlin depth 3.4 -> 2.8 (reading 2.219 -> 1.813), go depth 1.6 -> 1.4 (1.169 -> 0.999), csharp depth 2.2 -> 2.0 (1.438 -> 1.279), java depth 2.2 -> 2.1 (1.402 -> 1.354), kotlin collide_scaling 1.8 -> 1.65 (1.179 -> 1.081), go collide_scaling 5.5 -> 5.1 (3.763 -> 3.465). The ABSOLUTE ms ceilings were deliberately NOT tightened by the same reasoning: they carry runner-contention headroom rather than measurement headroom, and a ratio is runner-speed-invariant where a millisecond is not.", + "_floor": "Measured against the pre-change implementations on THIS corpus at 150/600 files: go 3.36, csharp 4.10, dart 3.32, ruby 3.87. The issues report 4.00 / 3.43 / 4.05 on their own corpora; those are DIFFERENT numbers from different repositories and are not reproduced here \u2014 what they and these share is that both independently land in the quadratic band, well clear of the ~1.0 a linear result gives. Note also that this floor was taken at 150/600 while the gate runs at 400/1600, so it is a lower bound on what the pre-change code would score today. Kotlin's own bench measured its pre-index floor at 3.737. The four resolvers added later were NOT re-floored on this corpus, and the reason is that they do not need to be: every one of their pre-change legs walked the whole file set per import (PHP one findIndex per path part per extension, Java one scan per stripped prefix, COBOL two full scans per COPY, C# csproj one normalizedFileList pass per import per matching config), so their scaling_ratio is ~4 by construction rather than by measurement. Their per-import costs were measured on their own issue corpora instead: PHP 96.40 ms -> 0.036 ms, Java 8.05 ms -> 0.62 ms, COBOL 3879 us -> 10.5 us, C# csproj 1103 us -> 7.6 us. The 1.8 budget sits well above the linear result and well below every one of those. The eight languages added last were NOT floored either, and for a different reason again: they are not fixes, so there is no pre-change implementation to floor against. Their scaling budgets are the global linear 1.8 and the point of the arms is to hold the current numbers (measured 1.01-1.13) rather than to separate a fix from a break. The one exception is javascript, which IS a fix and does have a floor: 6448.9 us per import at 2000 files and 25972.6 us at 8000 \u2014 4.12x the per-import cost for 4x the files, i.e. O(imports x files) \u2014 against 28.5 / 27.4 us with the index PR #2911 gave it, and 25.0 / 27.0 us for TypeScript over the identical corpus.", + "_rebaselined_2910_java_declared_packages": "#2910 replaces Java path-suffix fallback with declared-package resolution. The benchmark now restores package capture side channels, threads parsedFiles through javaScopeResolver, proves the context leg with a positive path/package-mismatch probe, and models the collide arm as one package declared across service paths. External imports now remain unresolved; local exact and wildcard imports preserve the 1153/4681 workload. Java's index is package/type maps rather than suffix maps: bytes_large 34958600 -> 3676984, with its floor and ceiling re-recorded together. Depth and collision scaling budgets tighten to the shared linear 1.8 gate.", + "scaling_budget": 1.8, + "collide_scaling_budget": { + "go": 5.1, + "csharp": 3.4, + "csharp_csproj": 1.8, + "dart": 3.3, + "ruby": 1.8, + "kotlin": 1.65, + "php": 1.8, + "java": 1.8, + "cobol": 1.8, + "swift": 4.9, + "rust": 1.8, + "python": 1.8, + "javascript": 1.8, + "typescript": 1.8, + "vue": 1.8, + "c": 3.8, + "cpp": 4 + }, + "depth_budget": { + "go": 1.4, + "csharp": 2.0, + "csharp_csproj": 2.3, + "dart": 1.6, + "ruby": 2.2, + "kotlin": 1.8, + "php": 1.8, + "java": 1.8, + "cobol": 1.6, + "swift": 2.3, + "rust": 2.1, + "python": 2.2, + "javascript": 2.6, + "typescript": 2.6, + "vue": 2.3, + "c": 3, + "cpp": 3 + }, + "small_ms_ceiling": { + "go": 7, + "csharp": 11, + "csharp_csproj": 97, + "dart": 3, + "ruby": 77, + "kotlin": 12, + "php": 55, + "java": 17, + "cobol": 2, + "swift": 2, + "rust": 10, + "python": 18, + "javascript": 85, + "typescript": 85, + "vue": 81, + "c": 7, + "cpp": 7 + }, + "collide_ms_ceiling": { + "go": 28, + "csharp": 22, + "csharp_csproj": 105, + "dart": 6, + "ruby": 95, + "kotlin": 12, + "php": 60, + "java": 26, + "cobol": 1.5, + "swift": 4, + "rust": 19, + "python": 19, + "javascript": 89, + "typescript": 86, + "vue": 93, + "c": 11, + "cpp": 12 + }, + "heap_ceiling_bytes": { + "kotlin": 6800000, + "go": 4497696, + "dart": 11751300, + "cpp": 15035016, + "csharp": 44900000, + "csharp_csproj": 110600000, + "ruby": 61600000, + "php": 59410824, + "java": 5600000, + "python": 9541404, + "c": 15000000 + }, + "_heap_reading_note": "heap_reading_bytes records each measured large-corpus reading so the 0.5x floor is independent of its ceiling. Ceilings use the standard 1.5x allowance for cross-platform and Node heap-accounting differences; re-baseline the reading and ceiling together.", + "_kotlin_declared_package_gate": "#2960 replaces Kotlin\u0027s path-suffix cascade with one declared-package/module-binding index. The 32000-file arm measured 4516944 retained bytes (4.31 MiB), with a 6800000-byte ceiling. Correctness and reuse are pinned separately by kotlin-module-resolution.test.ts, external-import-conformance.test.ts, the shared index-reuse contract, and bench/kotlin-import-target.", + "heap_reading_bytes": { + "kotlin": 4516944, + "go": 2998464, + "dart": 7834200, + "cpp": 10023344, + "csharp": 29869080, + "csharp_csproj": 73703384, + "ruby": 41020808, + "php": 39607216, + "java": 3676984, + "python": 6360936, + "c": 10018816 + }, + "_heap_bound_note": "THE SECOND HEAP TIER. Every registered language is measured now; heap_bound_bytes gates the nine that are not BUDGETED above, and it gates them with one comparison and no floor. A ceiling says 'this index is not too big'. A bound says something narrower and it is the thing that was missing: 'the exclusion still holds' \u2014 this language has not grown an index since it was left out. measure.mjs's MEMORY section states the re-entry condition (if a language ever diverges in what it ASKS its index, it earns a budgeted arm) and until now nothing watched for the divergence; HEAP_LANGS was a hand-maintained list of eight whose two neighbours, LANG_REGISTRY and CONTEXT_LANGS, are both reconciled against a derived predicate in both directions. HEAP_BOUNDED is derived too \u2014 it is LANGS minus HEAP_BUDGETED \u2014 so the two tiers partition the languages and a new one cannot land outside both. WHAT RE-MEASURING FOUND, five runs each, maximum quoted, peak-to-peak in brackets. go 2998464 B [1.0021], dart 7834200 B [1.0006] and kotlin 42802456 B [1.0004] HAD NO STATED REASON AT ALL: the old prose opened 'SIX of the seventeen are deliberately NOT in HEAP_LANGS' against a list of eight of seventeen, and these three were the three nobody counted. All three retain a real per-pass structure (go's PackageDirIndex, dart's basename buckets, kotlin's suffixByStem cascade) and kotlin's 40.82 MiB is above ruby's 39.12 and java's 33.34, both of which carry a full budget. (It read 45.85 MiB when this was written, described here as 'the second-largest reading in this file' \u2014 it was third even then, behind csharp_csproj and php; #2881 later compacted its dirChildren buckets and took 11% off it. Same staleness this paragraph exists to document.) swift 3449216 B [1.0024] and cobol 2320456 B [1.0000] were excluded as 'below the measurement's own noise floor' on readings of 0.29 MB and 0 B at 32000 files; they now read 3.29 MB and 2.21 MB, growing with the corpus (969120 B and 536264 B at 8000). Those old numbers were not wrong when taken \u2014 the ARM changed under them, when #2903's follow-up made every probe resolve a real import and when measureHeap began flattening its corpus \u2014 which is the whole finding: a measurement written into prose is not re-taken, and this file had already gone stale against itself, quoting javascript at 46208832 B four paragraphs after quoting it at 25.51 MiB. rust is the one exclusion that survived unchanged: 16 B at 8000 files and 16 B at 32000, identical in all five runs. typescript 26745296 B, vue 28884016 B and cpp 10023344 B are duplicates of a builder AND of a read pattern: typescript is byte-identical to javascript's 26745296 in four runs of five, cpp is +0.05% of c's 10018816, vue is +8.0% of javascript. HOW THE BOUNDS WERE CHOSEN. Each takes 1.5x its measured maximum, rounded up to the next 100000 B: cobol 3500000 (1.508x), swift 5200000 (1.508x). (This sentence used to list eight, including go, dart, kotlin, typescript, vue and cpp. Those six were promoted to the budgeted tier and their bounds deleted; the numbers stayed here, unread by any gate, and #2881 dutifully updated kotlin's to 64300000 before anyone noticed heap_bound_bytes holds only cobol, swift and rust. A number nothing asserts is a number that rots \u2014 the finding this paragraph is otherwise about.) 1.5x is NOT copied from the ceilings out of habit \u2014 it is the same number for a stated reason, and the reason is not noise: measured peak-to-peak on this box is at most 1.0024, so noise alone would justify 1.05x. What a bound has to survive is a RUNNER change, since heapUsed accounting moves across platforms and Node majors, and this file already fixes that allowance at 50% for exactly this measurement on exactly this arm. Using a second allowance for the same uncertainty on the same number would be two conventions, not more rigour. At 1.5x the bound catches what the re-entry condition is about \u2014 a language growing an index, which costs +85% for one more suffix map and +100% for a duplicate \u2014 and it does NOT catch a duplicate diverging by 8%. That limit is real and is stated rather than hidden: the tight form is a same-process ratio against the arm each duplicate is a duplicate OF, which is the only form immune to the drift the absolute bound has to tolerate. RUST TAKES AN ABSOLUTE BOUND INSTEAD, 1048576 B (1 MiB), because 1.5 x 16 B is 24 B and would fail on the first byte of anything \u2014 a multiplier on a reading that is already nothing is a gate that flakes rather than a gate that bites. 1 MiB is ~65000x the reading and still 2.2x below the smallest real index measured here (cobol's 2.32 MB at the same file count), so it separates 'builds nothing' from 'builds something' with room on both sides. NO FLOOR ON ANY OF THE NINE, and the reason differs by language rather than being uniform. For rust a floor would be a floor on noise. For the other eight the readings are stable enough to floor today, and for kotlin and dart \u2014 larger than budgeted arms \u2014 a floor would be worth having, since a lazily-built map going quiet is exactly how the four budgeted arms once read 0 B. Adding one is a PROMOTION to the budgeted tier, with a ceiling and a recorded reading beside it, not a line here: a floor whose companion ceiling does not exist asserts 'still measuring' against a number nothing else bounds. Recommended next, in order: kotlin, then dart, then go.", + "heap_bound_bytes": { + "cobol": 3500000, + "swift": 5200000, + "rust": 1048576, + "javascript": 1048576, + "typescript": 1048576, + "vue": 1048576 + }, + "heap_floor_fraction": 0.5, + "heap_ratio_budget": 1.25, + "languages": { + "go": { + "small": { + "files": 400, + "imports": 3200, + "resolved": 1153, + "distinct_outcomes": 2913, + "fingerprint": "f2ff032eb7dc4d8f37ecfc9b56d7fc846c5a78cf9a4dfd9f78f4e04588fd0a90" + }, + "large": { + "files": 1600, + "imports": 12800, + "resolved": 4681, + "distinct_outcomes": 11709, + "fingerprint": "19bd34ab249a95fe93843cfb3f5ab84f8215ed0eaccef30ca50298e8dcde1d87" + }, + "deep": { + "files": 400, + "imports": 3200, + "resolved": 1153, + "distinct_outcomes": 2913, + "fingerprint": "67f8fa6657625080e912e417047320928f23f87ecd7a3f45283ad31684752195" + }, + "collide": { + "files": 400, + "imports": 3200, + "resolved": 1153, + "distinct_outcomes": 2868, + "fingerprint": "8d82320278f74c0ebf7ba3e58fd49fde13e9927956f284774cbf39c3b8ca34a8" + }, + "collide_large": { + "files": 1600, + "imports": 12800, + "resolved": 4681, + "distinct_outcomes": 11570, + "fingerprint": "f9c777dc06e32edd30570a5f9316531481941d1f91930e86e2f2bc8dbdf7d6a7" + }, + "fingerprint": "19bd34ab249a95fe93843cfb3f5ab84f8215ed0eaccef30ca50298e8dcde1d87", + "heap": { + "files_small": 8000, + "files_large": 32000, + "path_segments": 14, + "probe": "example.com/mod/repo0/pkg/util" + }, + "_measured": { + "collide_ms": 7.15, + "collide_scaling_ratio": 3.465, + "depth_ratio": 0.999, + "scaling_ratio": 0.985, + "small_ms": 1.475 + } + }, + "csharp": { + "small": { + "files": 400, + "imports": 3200, + "resolved": 1153, + "distinct_outcomes": 2844, + "fingerprint": "503dbb3c2fd97d2fa380bc7d77d11706b42878a034a92dbc9a55620f88c53c76" + }, + "large": { + "files": 1600, + "imports": 12800, + "resolved": 4681, + "distinct_outcomes": 11440, + "fingerprint": "1145ce5736bfea02dcd948eac9e1d263d67470f661b7bafb30061887745d2bf5" + }, + "deep": { + "files": 400, + "imports": 3200, + "resolved": 1153, + "distinct_outcomes": 2844, + "fingerprint": "36df304d03f1d0e05e4883e69d91b95728468fdc7c7147eaa357c0ad1d022fd6" + }, + "collide": { + "files": 400, + "imports": 3200, + "resolved": 1153, + "distinct_outcomes": 2844, + "fingerprint": "557c92c82c8960723f0d3ce4bf13f7d661e822d98d9597fe0f5e7c6eaf988f68" + }, + "collide_large": { + "files": 1600, + "imports": 12800, + "resolved": 4681, + "distinct_outcomes": 11440, + "fingerprint": "dbcab955f88895058613b0fb5b9ac81504c7bdc27344e3eab6a6272a14127796" + }, + "fingerprint": "1145ce5736bfea02dcd948eac9e1d263d67470f661b7bafb30061887745d2bf5", + "heap": { + "files_small": 8000, + "files_large": 32000, + "path_segments": 13, + "probe": "Ghost0.Deep.Missing" + }, + "_measured": { + "collide_ms": 5.167, + "collide_scaling_ratio": 2.265, + "depth_ratio": 1.279, + "scaling_ratio": 1.043, + "small_ms": 2.45 + } + }, + "csharp_csproj": { + "small": { + "files": 400, + "imports": 3200, + "resolved": 979, + "distinct_outcomes": 2983, + "fingerprint": "b63d7f2b8078cce64d87a6c93e331db0a6045686cdc0dd70abe3ac2b0bab19d2" + }, + "large": { + "files": 1600, + "imports": 12800, + "resolved": 4064, + "distinct_outcomes": 12029, + "fingerprint": "d9f161410c06c0e73e18ca0f27d6e253402dfe06c9918673a52f04daecb23e36" + }, + "deep": { + "files": 400, + "imports": 3200, + "resolved": 979, + "distinct_outcomes": 2983, + "fingerprint": "7ba2a8ff5151911aa556d809219e9ba5b64c7a022bbfa7f225f08e5d60ab2c62" + }, + "collide": { + "files": 400, + "imports": 3200, + "resolved": 979, + "distinct_outcomes": 2983, + "fingerprint": "fb815bbcfeb4f1049d63f38487ca9e3ada2fcc14ba8478bcc2976e2f632697e1" + }, + "collide_large": { + "files": 1600, + "imports": 12800, + "resolved": 4064, + "distinct_outcomes": 12029, + "fingerprint": "f06217a605d83cf66076a0d55a202dfa13fd4adb55f5605b6154135d0ff745dc" + }, + "fingerprint": "d9f161410c06c0e73e18ca0f27d6e253402dfe06c9918673a52f04daecb23e36", + "heap": { + "files_small": 8000, + "files_large": 32000, + "path_segments": 13, + "probe": "App.Missing0" + }, + "_measured": { + "collide_ms": 25.185, + "collide_scaling_ratio": 1.146, + "depth_ratio": 1.394, + "scaling_ratio": 1.182, + "small_ms": 23.634 + } + }, + "dart": { + "small": { + "files": 400, + "imports": 3200, + "resolved": 1153, + "distinct_outcomes": 2987, + "fingerprint": "318084f48ffa4eeae4a5b7fc25916d4ad78673d92dba62b7e462d1bb87ca553a" + }, + "large": { + "files": 1600, + "imports": 12800, + "resolved": 4681, + "distinct_outcomes": 11875, + "fingerprint": "5151cd2498bd4b7698dc9309e2539977d306f9ba82a388c630c89b51fc4a3187" + }, + "deep": { + "files": 400, + "imports": 3200, + "resolved": 1153, + "distinct_outcomes": 2987, + "fingerprint": "79776ec1c22afa619fd31aeb05dcac567723b436d0461a5782693b9e929f6f74" + }, + "collide": { + "files": 400, + "imports": 3200, + "resolved": 1153, + "distinct_outcomes": 2999, + "fingerprint": "1b145a4c3b41ffc4efa26f74449c3d44646d6d59728163ac896fc0ee25c6d608" + }, + "collide_large": { + "files": 1600, + "imports": 12800, + "resolved": 4681, + "distinct_outcomes": 11948, + "fingerprint": "b7e5303220b8fa64e85c7e17622961018316a10c5ef921a02584864309748b52" + }, + "fingerprint": "5151cd2498bd4b7698dc9309e2539977d306f9ba82a388c630c89b51fc4a3187", + "heap": { + "files_small": 8000, + "files_large": 32000, + "path_segments": 11, + "probe": "package:ext0/src/thing.dart" + }, + "_measured": { + "collide_ms": 1.511, + "collide_scaling_ratio": 2.319, + "depth_ratio": 1.169, + "scaling_ratio": 1.071, + "small_ms": 0.542 + } + }, + "ruby": { + "small": { + "files": 400, + "imports": 3200, + "resolved": 1153, + "distinct_outcomes": 2936, + "fingerprint": "54abc79cc3fd4bfbf341119f3c511c2d64d55556a0c23984032f34e259283e46" + }, + "large": { + "files": 1600, + "imports": 12800, + "resolved": 4681, + "distinct_outcomes": 11786, + "fingerprint": "31804ae9633d51ce7597d886f9c2230fec3448b0aa00d9ab953086e393cd28a9" + }, + "deep": { + "files": 400, + "imports": 3200, + "resolved": 1153, + "distinct_outcomes": 2936, + "fingerprint": "a6dac0609e6800571bcc19ee30818ab571549f275d691f98e4c238aaa4fb1362" + }, + "collide": { + "files": 400, + "imports": 3200, + "resolved": 1153, + "distinct_outcomes": 2936, + "fingerprint": "065fb3a97e1fa01416396b128b1a03d4ea5b49634cb3ffed4b81a07b251c2f2e" + }, + "collide_large": { + "files": 1600, + "imports": 12800, + "resolved": 4681, + "distinct_outcomes": 11786, + "fingerprint": "55a3afc06a48334a6dd2f29c730ae0cfd3a6d54f3013c0853a310af2bbcba277" + }, + "fingerprint": "31804ae9633d51ce7597d886f9c2230fec3448b0aa00d9ab953086e393cd28a9", + "heap": { + "files_small": 8000, + "files_large": 32000, + "path_segments": 11, + "probe": "gem0/missing/thing" + }, + "_measured": { + "collide_ms": 20.732, + "collide_scaling_ratio": 1.119, + "depth_ratio": 1.257, + "scaling_ratio": 1.133, + "small_ms": 19.994 + } + }, + "kotlin": { + "small": { + "files": 400, + "imports": 3200, + "resolved": 1153, + "distinct_outcomes": 2868, + "fingerprint": "66cbb7ff88b86aae43b0d712a07803cb2b661325574935d4f60c44bb400ca8d5" + }, + "large": { + "files": 1600, + "imports": 12800, + "resolved": 4681, + "distinct_outcomes": 11512, + "fingerprint": "1553ccabe44914abac14e23634de8edfb86fd4112c82fdc2f972c39014545502" + }, + "deep": { + "files": 400, + "imports": 3200, + "resolved": 1153, + "distinct_outcomes": 2868, + "fingerprint": "03344f76247e05e44deab733adce11b28a0ee84dafdcbbdc287ff253b48fc222" + }, + "collide": { + "files": 400, + "imports": 3200, + "resolved": 1153, + "distinct_outcomes": 2744, + "fingerprint": "80887d0f64e1439feeeb999289098f248a068b2581c68be8800bbf437fe729e3" + }, + "collide_large": { + "files": 1600, + "imports": 12800, + "resolved": 4681, + "distinct_outcomes": 10961, + "fingerprint": "1d83a55a51dc5924e71fcf530529b49dd9e5afffbb4d0fb8cf2c34e13fdc2713" + }, + "fingerprint": "1553ccabe44914abac14e23634de8edfb86fd4112c82fdc2f972c39014545502", + "heap": { + "files_small": 8000, + "files_large": 32000, + "path_segments": 18, + "probe": "com.ghost0.deep.Missing" + }, + "context": { + "target": "com.example.model.User", + "with_context": "weird/path/UserSource.kt", + "without_context": "" + }, + "_measured": { + "collide_ms": 4.687, + "collide_scaling_ratio": 1.046, + "depth_ratio": 0.968, + "scaling_ratio": 1.063, + "small_ms": 5.244 + } + }, + "php": { + "small": { + "files": 400, + "imports": 3200, + "resolved": 1152, + "distinct_outcomes": 2871, + "fingerprint": "0e9b0839544137054dcc5a9fcc9c6972fee954c2b8780905d79201556a7e4315" + }, + "large": { + "files": 1600, + "imports": 12800, + "resolved": 4680, + "distinct_outcomes": 11517, + "fingerprint": "f69730d7df13cd12b59344d596d4918a718c6eda62d4179b296b5f8174af7d88" + }, + "deep": { + "files": 400, + "imports": 3200, + "resolved": 1152, + "distinct_outcomes": 2871, + "fingerprint": "ded2c1504ff813c596b74093f9352c25b358ad1e67c78e61dd028b57ef05ae61" + }, + "collide": { + "files": 400, + "imports": 3200, + "resolved": 1152, + "distinct_outcomes": 2871, + "fingerprint": "76c89603524105061b0a9032587702c5ff1d59d8233527799b0515f1f926960e" + }, + "collide_large": { + "files": 1600, + "imports": 12800, + "resolved": 4680, + "distinct_outcomes": 11517, + "fingerprint": "e88e95736fd8a0f9b27fcb363136c582fe94bcf0885e41efbb4307c367f97f50" + }, + "fingerprint": "f69730d7df13cd12b59344d596d4918a718c6eda62d4179b296b5f8174af7d88", + "heap": { + "files_small": 8000, + "files_large": 32000, + "path_segments": 14, + "probe": "App\\HeapGhost0\\AbsentHeapProbe", + "resolution_config": "App=d0/d1/d2/d3/d4/d5/d6/d7/src/App", + "external_probe": "Vendor0\\Ghost\\Missing", + "external_result": "" + }, + "context": { + "target": "App\\Ns0\\Dup", + "with_context": "src/App/Ns0/Helpers.php", + "without_context": "src/App/Ns0/Dup.php" + }, + "_measured": { + "collide_ms": 10.581, + "collide_scaling_ratio": 1.026, + "depth_ratio": 1.158, + "scaling_ratio": 1.05, + "small_ms": 10.511 + } + }, + "java": { + "small": { + "files": 400, + "imports": 3200, + "resolved": 1153, + "distinct_outcomes": 2868, + "fingerprint": "8e347c485c47a1a9f67ae0183b68327b40b1e5d3510160fda2a2fe54c0f8a453" + }, + "large": { + "files": 1600, + "imports": 12800, + "resolved": 4681, + "distinct_outcomes": 11512, + "fingerprint": "6773de19833d9936cb098c5897b5a44ba19b07bd8990af5a8b70fc03b309794f" + }, + "deep": { + "files": 400, + "imports": 3200, + "resolved": 1153, + "distinct_outcomes": 2868, + "fingerprint": "0ba22e27f87395535533bac3270c481ea50aa4cd7eba4958325ef792f308bfc0" + }, + "collide": { + "files": 400, + "imports": 3200, + "resolved": 1153, + "distinct_outcomes": 2744, + "fingerprint": "2ab215bd5109f13c0f15513c3bf578ca467896dd28d489c9ac4477d2b647c39f" + }, + "collide_large": { + "files": 1600, + "imports": 12800, + "resolved": 4681, + "distinct_outcomes": 10961, + "fingerprint": "31e762ed838528a426e5cc4510956661fc2aef7aa7c743291d75fcec54e46235" + }, + "fingerprint": "6773de19833d9936cb098c5897b5a44ba19b07bd8990af5a8b70fc03b309794f", + "heap": { + "files_small": 8000, + "files_large": 32000, + "path_segments": 18, + "probe": "com.google.common.vendor0.Missing" + }, + "context": { + "target": "com.example.model.User", + "with_context": "weird/path/User.java", + "without_context": "" + }, + "_measured": { + "collide_ms": 5.219, + "collide_scaling_ratio": 1.07, + "depth_ratio": 0.978, + "scaling_ratio": 1.0, + "small_ms": 5.674 + } + }, + "cobol": { + "small": { + "files": 400, + "imports": 3200, + "resolved": 1153, + "distinct_outcomes": 2941, + "fingerprint": "e5bf9c2a74cad64df6ac18299b56fc9139943baec6118036b2e765ac3d4252f2" + }, + "large": { + "files": 1600, + "imports": 12800, + "resolved": 4681, + "distinct_outcomes": 11791, + "fingerprint": "f192ca7a9e87eb05f03893ffc64252a8aba2c638604dcf449150fb9b5fdd989e" + }, + "deep": { + "files": 400, + "imports": 3200, + "resolved": 1153, + "distinct_outcomes": 2941, + "fingerprint": "c690c6abc5c7aab31f27a97e5ef25d32daa483d9c08ceb48bc0b85ac406e6e37" + }, + "collide": { + "files": 400, + "imports": 3200, + "resolved": 1153, + "distinct_outcomes": 2827, + "fingerprint": "c487db2efbf7a683674de84430d88e7a4c75e9427a53cccae934fd8440b85d87" + }, + "collide_large": { + "files": 1600, + "imports": 12800, + "resolved": 4681, + "distinct_outcomes": 11393, + "fingerprint": "8bc1d506b54e800d060eb4c92fca01c5b06c5a7130248dc1a79521bdea53982a" + }, + "fingerprint": "f192ca7a9e87eb05f03893ffc64252a8aba2c638604dcf449150fb9b5fdd989e", + "heap": { + "files_small": 8000, + "files_large": 32000, + "path_segments": 11, + "probe": "VENDOR0" + }, + "_measured": { + "collide_ms": 0.197, + "collide_scaling_ratio": 1.046, + "depth_ratio": 0.885, + "scaling_ratio": 0.936, + "small_ms": 0.286 + } + }, + "swift": { + "small": { + "files": 400, + "imports": 3200, + "resolved": 1153, + "distinct_outcomes": 2913, + "fingerprint": "91c5172b994270807f7fdcf80ac545edd50d3dc87c67290c9aabaed8bb65d594" + }, + "large": { + "files": 1600, + "imports": 12800, + "resolved": 4681, + "distinct_outcomes": 11709, + "fingerprint": "16f80a95e52ad1057cf369b7816ce704684fc5b5663a39f23c9149e9221c6170" + }, + "deep": { + "files": 400, + "imports": 3200, + "resolved": 1153, + "distinct_outcomes": 2913, + "fingerprint": "22ef94a6e087d7ac909733ef32da2ddf292fa8c82b05b70b5910c307fceca1b4" + }, + "collide": { + "files": 400, + "imports": 3200, + "resolved": 1153, + "distinct_outcomes": 2606, + "fingerprint": "4d2c41ba5f8230ab6b9faade80f293ddde153f4ff1d5dd4473f1cd699d2808fd" + }, + "collide_large": { + "files": 1600, + "imports": 12800, + "resolved": 4681, + "distinct_outcomes": 10184, + "fingerprint": "d27b6070f5ad93020bf762221e798c382327406f3f2cca2f7aab3e9ac56faef4" + }, + "fingerprint": "16f80a95e52ad1057cf369b7816ce704684fc5b5663a39f23c9149e9221c6170", + "heap": { + "files_small": 8000, + "files_large": 32000, + "path_segments": 13, + "probe": "ExternalPkg0" + }, + "_measured": { + "collide_ms": 0.819, + "collide_scaling_ratio": 3.454, + "depth_ratio": 1.496, + "scaling_ratio": 1.063, + "small_ms": 0.385 + } + }, + "rust": { + "small": { + "files": 400, + "imports": 3200, + "resolved": 979, + "distinct_outcomes": 2844, + "fingerprint": "6a2435149e055e6903aab2dd3fa2a0986d8d1d7933bccb0b7f311448372f548c" + }, + "large": { + "files": 1600, + "imports": 12800, + "resolved": 4064, + "distinct_outcomes": 11440, + "fingerprint": "442f9124ebeb052557413d9dbb7c5e467ffc69da6357d0d8d9bcd2232ba27092" + }, + "deep": { + "files": 400, + "imports": 3200, + "resolved": 979, + "distinct_outcomes": 2844, + "fingerprint": "aa32ea032a548df09554c40a8b0679f11bc4d4cefc1dcad941283036dbae7c8e" + }, + "collide": { + "files": 400, + "imports": 3200, + "resolved": 979, + "distinct_outcomes": 2844, + "fingerprint": "4d1e28e318a04ee0e2065b5f9a2c971765e2f60310b0e612cfd327c76b6344b6" + }, + "collide_large": { + "files": 1600, + "imports": 12800, + "resolved": 4064, + "distinct_outcomes": 11440, + "fingerprint": "3de5234747493741abbf170e164ef80188092747150cfb39ef2cacd5658effc0" + }, + "fingerprint": "442f9124ebeb052557413d9dbb7c5e467ffc69da6357d0d8d9bcd2232ba27092", + "heap": { + "files_small": 8000, + "files_large": 32000, + "path_segments": 12, + "probe": "ghost0::Missing" + }, + "_measured": { + "collide_ms": 4.767, + "collide_scaling_ratio": 1.042, + "depth_ratio": 1.371, + "scaling_ratio": 1.097, + "small_ms": 2.523 + } + }, + "python": { + "small": { + "files": 400, + "imports": 3200, + "resolved": 845, + "distinct_outcomes": 2867, + "fingerprint": "7a458789903c904968af8f9f851656ea33c1c446959ca79a0222ec65d3e809ed" + }, + "large": { + "files": 1600, + "imports": 12800, + "resolved": 3556, + "distinct_outcomes": 11517, + "fingerprint": "98f99b9eaa3fcc3c58c4be0116853789c8e1299c187088a8b04d28f1885c944a" + }, + "deep": { + "files": 400, + "imports": 3200, + "resolved": 845, + "distinct_outcomes": 2867, + "fingerprint": "c099814a70bbb63471fecc6e9527632e83b9954618963e77648f893ddfe65286" + }, + "collide": { + "files": 400, + "imports": 3200, + "resolved": 845, + "distinct_outcomes": 2867, + "fingerprint": "038c097cb628f6c65c1a228a5df3bb29a81eb3d4d7f297cfe86c3c4c6323c7c0" + }, + "collide_large": { + "files": 1600, + "imports": 12800, + "resolved": 3556, + "distinct_outcomes": 11517, + "fingerprint": "94cd4994ce690db215028ff42f06aa1fd142bd26d62a290f4849bbff36c294f5" + }, + "fingerprint": "98f99b9eaa3fcc3c58c4be0116853789c8e1299c187088a8b04d28f1885c944a", + "heap": { + "files_small": 8000, + "files_large": 32000, + "path_segments": 11, + "probe": "vendor0.deep.missing" + }, + "context": { + "target": "pkg", + "with_context": "pkg/__init__.py", + "without_context": "pkg/X.py" + }, + "_measured": { + "collide_ms": 4.521, + "collide_scaling_ratio": 1.085, + "depth_ratio": 1.563, + "scaling_ratio": 1.144, + "small_ms": 4.431 + } + }, + "javascript": { + "small": { + "files": 400, + "imports": 3200, + "resolved": 1153, + "distinct_outcomes": 2867, + "fingerprint": "4a80c7b940a6c39d0ebd109980469d2398b417a4479f5d03f35abc482fa76122" + }, + "large": { + "files": 1600, + "imports": 12800, + "resolved": 4681, + "distinct_outcomes": 11517, + "fingerprint": "827ac421e8958ff686b2877efa60fbaf1a1c661698cde8f0c8c931919e0f35bd" + }, + "deep": { + "files": 400, + "imports": 3200, + "resolved": 1153, + "distinct_outcomes": 2867, + "fingerprint": "4d0551e044b19bccd9775879b1425f3adecf0cdf1e90a5d8a9607ef0a51af880" + }, + "collide": { + "files": 400, + "imports": 3200, + "resolved": 1153, + "distinct_outcomes": 2871, + "fingerprint": "45b4bb3b2f9797e21029ef1eef7247702813cac39ac430f9a999d3326596a7a1" + }, + "collide_large": { + "files": 1600, + "imports": 12800, + "resolved": 4681, + "distinct_outcomes": 11548, + "fingerprint": "304ed93e83b397b4aa9520750739ffdcf3a9b353ae0ad8fe88c0368c63c85533" + }, + "fingerprint": "827ac421e8958ff686b2877efa60fbaf1a1c661698cde8f0c8c931919e0f35bd", + "heap": { + "files_small": 8000, + "files_large": 32000, + "path_segments": 11, + "probe": "vendor0/lib/missing" + }, + "_measured": { + "collide_ms": 22.96, + "collide_scaling_ratio": 1.077, + "depth_ratio": 1.213, + "scaling_ratio": 1.093, + "small_ms": 22.762 + } + }, + "typescript": { + "small": { + "files": 400, + "imports": 3200, + "resolved": 1153, + "distinct_outcomes": 2867, + "fingerprint": "fe8fcf81efa0fa3a77894edc6bd0b9ec4ff0bf92f24e116ddf108dc70cdcd97e" + }, + "large": { + "files": 1600, + "imports": 12800, + "resolved": 4681, + "distinct_outcomes": 11517, + "fingerprint": "24e36ebfc1c482643812f1ef400e8cb387dae11954531407e113d4e6c3fa2a6d" + }, + "deep": { + "files": 400, + "imports": 3200, + "resolved": 1153, + "distinct_outcomes": 2867, + "fingerprint": "e9faf31f6b1299394760e27ff9e04af1a8b4ddca0370db62fd2a59af7a4f5d05" + }, + "collide": { + "files": 400, + "imports": 3200, + "resolved": 1153, + "distinct_outcomes": 2871, + "fingerprint": "dbb68b66e8d136140f4a7bc024c97f5de02d1c8b6f3a07efa271dcb73366eeb5" + }, + "collide_large": { + "files": 1600, + "imports": 12800, + "resolved": 4681, + "distinct_outcomes": 11548, + "fingerprint": "3129a6f1f25bd5568058682f99812ad35e38231b92025184344b74b86fa2e910" + }, + "fingerprint": "24e36ebfc1c482643812f1ef400e8cb387dae11954531407e113d4e6c3fa2a6d", + "heap": { + "files_small": 8000, + "files_large": 32000, + "path_segments": 11, + "probe": "vendor0/lib/missing" + }, + "_measured": { + "collide_ms": 22.324, + "collide_scaling_ratio": 1.059, + "depth_ratio": 1.25, + "scaling_ratio": 1.079, + "small_ms": 20.882 + } + }, + "vue": { + "small": { + "files": 400, + "imports": 3200, + "resolved": 1153, + "distinct_outcomes": 2867, + "fingerprint": "88e85b85f7158cc87d770f119c992d71801c4c692da13064cbc8b95718517fe4" + }, + "large": { + "files": 1600, + "imports": 12800, + "resolved": 4681, + "distinct_outcomes": 11517, + "fingerprint": "4d62ac179e4371d3f41b691cea725b90272f72da625e914d2f16d323a1e940c8" + }, + "deep": { + "files": 400, + "imports": 3200, + "resolved": 1153, + "distinct_outcomes": 2867, + "fingerprint": "786c801ad824c3e49f05aaf37a63c3bfe7dbe6dd2fb44cfef180099f4fcdd401" + }, + "collide": { + "files": 400, + "imports": 3200, + "resolved": 1153, + "distinct_outcomes": 2871, + "fingerprint": "8a01ee06ddf2eeb0db72dd8b73544180bf48d8cb82b6e73b1969233842553636" + }, + "collide_large": { + "files": 1600, + "imports": 12800, + "resolved": 4681, + "distinct_outcomes": 11548, + "fingerprint": "841b48a4c46fc56cd700ff7c07a515da1139cc4528d479a47876cbed291d91a4" + }, + "fingerprint": "4d62ac179e4371d3f41b691cea725b90272f72da625e914d2f16d323a1e940c8", + "heap": { + "files_small": 8000, + "files_large": 32000, + "path_segments": 11, + "probe": "vendor0/lib/Missing.vue" + }, + "_measured": { + "collide_ms": 24.453, + "collide_scaling_ratio": 1.095, + "depth_ratio": 1.384, + "scaling_ratio": 1.071, + "small_ms": 21.765 + } + }, + "c": { + "small": { + "files": 400, + "imports": 3200, + "resolved": 1153, + "distinct_outcomes": 2863, + "fingerprint": "4dd05ba9a0c6731d449ec555f56d2f7cb05cdcdaa01a8acc242e3709d305184f" + }, + "large": { + "files": 1600, + "imports": 12800, + "resolved": 4681, + "distinct_outcomes": 11512, + "fingerprint": "70d6064fdc08e86036ced58393585afc3693ee527f00983847299e390b413d87" + }, + "deep": { + "files": 400, + "imports": 3200, + "resolved": 1153, + "distinct_outcomes": 2863, + "fingerprint": "43707e57b1079e7f01cc84ea5ab891cf77c395e2d52e7fbb7eee30c058c1d667" + }, + "collide": { + "files": 400, + "imports": 3200, + "resolved": 1153, + "distinct_outcomes": 2695, + "fingerprint": "982b925fdbbc59d05ae52be1f405f3cbb6fd554390ee38eeff869df9316ffaf2" + }, + "collide_large": { + "files": 1600, + "imports": 12800, + "resolved": 4681, + "distinct_outcomes": 10845, + "fingerprint": "70b30b89ae671208bd836693fbc87d6059656cf347b9397d3b905d24e31912cf" + }, + "fingerprint": "70d6064fdc08e86036ced58393585afc3693ee527f00983847299e390b413d87", + "heap": { + "files_small": 8000, + "files_large": 32000, + "path_segments": 11, + "probe": "vendor0/missing.h" + }, + "_measured": { + "collide_ms": 2.924, + "collide_scaling_ratio": 2.575, + "depth_ratio": 1.938, + "scaling_ratio": 1.033, + "small_ms": 1.649 + } + }, + "cpp": { + "small": { + "files": 400, + "imports": 3200, + "resolved": 1153, + "distinct_outcomes": 2863, + "fingerprint": "6c199e829226c1cdd86e74611b159ff2e83d4c17da2a72552503a7c4518182e4" + }, + "large": { + "files": 1600, + "imports": 12800, + "resolved": 4681, + "distinct_outcomes": 11512, + "fingerprint": "191bddd6f77a10ab6bced04e5c5f55e0af4481563ef3cb57e08c5dfa6c86454e" + }, + "deep": { + "files": 400, + "imports": 3200, + "resolved": 1153, + "distinct_outcomes": 2863, + "fingerprint": "5603c080739321bb6204c153ec6214dc185e436b5aadb5ec13738e2a759a84f3" + }, + "collide": { + "files": 400, + "imports": 3200, + "resolved": 1153, + "distinct_outcomes": 2695, + "fingerprint": "cbf2fece6338725205beaf87058ce32ae1ba0860d14cedd29b7904b2f3a63726" + }, + "collide_large": { + "files": 1600, + "imports": 12800, + "resolved": 4681, + "distinct_outcomes": 10845, + "fingerprint": "094f7fe2aace191d7e53c2d4ecd7e063cf15bd66643e6201fd46e45b6b63ab6e" + }, + "fingerprint": "191bddd6f77a10ab6bced04e5c5f55e0af4481563ef3cb57e08c5dfa6c86454e", + "heap": { + "files_small": 8000, + "files_large": 32000, + "path_segments": 11, + "probe": "vendor0/missing.hpp" + }, + "_measured": { + "collide_ms": 3.035, + "collide_scaling_ratio": 2.566, + "depth_ratio": 2.064, + "scaling_ratio": 1.167, + "small_ms": 1.626 + } + } + }, + "_blind_spot": "MEASURED, so nobody has to rediscover it: a full workspace scan reintroduced on 1-in-32 imports passes EVERY arm here \u2014 dart scored 1.458 scaling and 1.736 ms against the 1.8 budget and 4 ms ceiling of an earlier revision. At 1-in-8 the scaling arm catches it (2.414). The gate that NARROWS this is not a timing gate at all: test/unit/scope-resolution/import-target-index-parity.test.ts counts iterations of the file-set Set and reads 14 instead of 1 for that same 1-in-32 mutation, deterministically and for all five languages. It does NOT close it. The counter watches the Set, and the resolvers no longer read the Set \u2014 they read materialized copies of the same file list: WorkspaceFileIndex.normalized and .all (C#, Ruby), Dart's byBasename buckets, and PackageDirIndex.filesByDir (Go, C#). A 1-in-32 scan over any of those three touches the Set zero extra times, so it passes the parity test AND passes --check. Closing it would take an iteration counter on the materialized arrays themselves. Read the two gates together; tightening these ceilings toward the noise floor to chase that case would only buy flaky CI. CONFIRMED THE HARD WAY by PR #2911: JavaScript resolution was scanning ImportPassCache.normalizedFileList on every import \u2014 a materialized array, not the Set \u2014 at 25972 us per import at 8000 files, and no instrument on the #2901-#2909 branch could see it. It took a differential parity test over 211200 old-vs-new pairs to find. The arms added here would have caught THAT one on absolute ms (85 ms budget against a 20 ms arm; the unindexed resolver costs ~83000 ms on the same corpus), which is the argument for gating every registered language rather than only the ones a PR happens to touch. THE SECOND BLIND SPOT IS CLOSED, and this records what closing it changed. This harness used to call the inner resolvers with the NO-CONTEXT shape: run.ts calls provider.resolveImportTarget with five arguments, the fifth being { parsedFiles, parsedImport }, and resolveOne supplied three. resolveOne now makes the production call, newPass mints the ParsedFile[] FIRST and derives the path set from it exactly as run.ts does, and both legs behind the argument run on every import of their arms \u2014 PHP's named/alias function-or-const leg over filesByDirectory(context.parsedFiles), whose memo defeated measures 197.0 us -> 9976.2 us per import (50.6x), and Python's from-import submodule-precedence branch, the only spelling that reads context.parsedFiles at all. Fifteen of the seventeen arms cannot observe a context (their hooks declare three or four parameters) and are handed none, so their numbers did not move; which two CAN is now reconciled against SCOPE_RESOLVERS' hook arity rather than asserted in prose. NOTHING ELSE IN THIS FILE COULD HAVE GATED IT, which is why the context arm exists: fingerprints and shape can remain unchanged while dropping context only makes timing faster. The deterministic context arm is therefore the guard for this wiring. The arm is one import per language resolved through resolveOne twice, with and without the pass's parsedFiles, whose two answers must DIFFER and must both match what is recorded. WHAT REMAINS UNMEASURED, narrowed rather than deleted: Python's parsedFileByPath memo is exercised by the five timing arms and cannot be reached by the heap arm at all, because retainedPassBytes requires a probe that MISSES while every path that builds that memo returns a non-null packageTarget \u2014 so no ceiling bounds that Map (one pointer per parsed file, O(files), no depth term) and the contract test's count gate is what holds it to one build per pass. PHP's sole arm carries a representative Composer PSR-4 map, so mapped hits and authoritative misses exercise that production branch directly. And the const tail of PHP's leg is a different ANSWER at the same cost \u2014 it runs the identical candidate gather and localDefs filter and diverges in the last two lines \u2014 so it is gated by count in test/unit/scope-resolution/import-target-index-reuse.contract.test.ts, which stays the gate to read alongside this file.", + "_depth_budget_note_2953": "javascript/typescript/vue moved from 2.0-2.1 to ~2.2 in #2953 and their budgets were raised to 2.6, which is a real shift with an understood cause rather than a loosened guard. Declared resolution never walks path components, so the deep arm's uniform d0/../d15/ prefix reaches these resolvers as the tsconfig baseUrl (see tsBaseUrlFor in measure.mjs) and every candidate string carries it: resolveFile probes ~11 extensions plus their /index forms, and hashing a 60-character path costs more than hashing a 12-character one. The growth is linear in path LENGTH and independent of file COUNT, which is what the ratio exists to bound - a resolver that started walking the corpus again would move scaling_ratio, not just this. Measured over three runs on a loaded box: js 2.109/2.257/2.240, ts 2.129/2.222/2.467, vue 2.116/2.151/2.102.", + "_heap_bound_note_2953": "javascript, typescript and vue moved from heap_reading_bytes/heap_ceiling_bytes to heap_bound_bytes in #2953. They retained 26745296 B (js, ts) and 28884016 B (vue) at 32000 files for a per-pass SuffixIndex over the whole file list; they now build no per-pass structure at all and read 0-16 B, because declared resolution derives nothing from the file set. That is a real saving rather than an arm that stopped measuring - the distinction this floor exists to make - and the evidence it is real is that the resolver fingerprints did NOT move: the same corpus resolves to the same targets, once the config it always implied is passed explicitly. The 1048576 B bound is rust's, chosen the same way: far above a 16 B reading, far below the index whose return it must catch." +} diff --git a/gitnexus/bench/import-target/measure.mjs b/gitnexus/bench/import-target/measure.mjs new file mode 100644 index 000000000..c7ea7cc0e --- /dev/null +++ b/gitnexus/bench/import-target/measure.mjs @@ -0,0 +1,2994 @@ +/** + * Build-free scaling + identity bench for EVERY import-target resolver in + * `SCOPE_RESOLVERS` — the registry decides which, not a list kept here, and the + * `--check` inventory arm at the foot of this file fails when the two disagree + * — over ONE shared corpus so the arms are directly comparable. One arm per + * registered language, plus a second `csharp` arm carrying csproj configs + * (#2902), so there is one more arm than there are languages. + * + * NO LANGUAGE IS OMITTED, and that is the point of the list rather than an + * accident of it. Nine of these arms (go, csharp, csharp_csproj, dart, ruby, + * kotlin, php, java, cobol) were added as their own O(imports × files) scans + * were indexed away — #2877/#2878/#2879/#2880, #2872, #2901, #2902, #2908 — and + * the bench is the forward guard on each. The eight added alongside them + * (swift, rust, python, javascript, typescript, vue, c, cpp) resolve imports + * through the same registered hook with the same per-run memoized indexes, and + * were ungated: nothing pinned their output and nothing pinned their scaling. + * One of them was not hypothetical — JavaScript reached `suffixResolve` with no + * index at all and measured 25 972 µs per import at 8000 files (PR #2911) — + * which is exactly the class of defect the other seven were one commit away + * from. + * + * A C or C++ `#include` is an import site for this purpose and is gated like + * every other registered language. See `newPass` for the one structural thing + * those two need that no other language does. + * + * Kotlin also has `bench/kotlin-import-target/`, and this does not replace it: + * that bench probes declared-package correctness cases shape by shape. What + * Kotlin gains here is a second corpus plus shared timing, context and heap + * arms. + * + * Each of the first nine resolvers answered its lookups with a full + * `allFilePaths` scan per import before its fix, so import resolution cost + * O(imports × files): + * + * - Go: `findRootPackageFiles` / `findAllFilesInPkgDir`, the latter once per + * path segment on the GOPATH fallback — several full scans per import; + * - C#: the no-csproj leg took the raw Set past the memoized index the csproj + * leg was already using — up to eight passes for a four-segment `using`; + * - Dart: one full scan per candidate path, and for an external package both + * candidates miss, so both always ran to completion; + * - Ruby: a complete `buildSuffixIndex` rebuilt and discarded per `require`; + * - PHP: two materialized arrays per import and then no index at all, which + * dropped `suffixResolve` onto a linear `findIndex` — one full pass per path + * part per extension, and there are ~50 extensions (96.40 ms per import at + * 20k files, now 0.036 ms); + * - Java: one scan for the direct match plus one more per stripped package + * prefix, and a JDK or third-party import runs the loop to the end (8.05 ms + * per import, now 0.62 ms); + * - COBOL: two scans per `COPY` — one per extension tier — each calling + * `extname` + `basename` + `toUpperCase` on every path, both always running + * to completion because vendor copybooks live outside the repo (3879 µs per + * import, now 10.5 µs); + * - C# csproj: the namespace-directory fallback re-scanned + * `normalizedFileList` per import per matching config (1103 µs, now 7.6 µs). + * `csharp` here builds its context with NO `csharpConfigs`, so it can never + * reach that leg — `csharp_csproj` is the same corpus with the configs + * supplied, and it exists because without it #2902 ships unmeasured. + * + * The eight added afterwards are not a second class of arm — they carry the + * same five timing arms, the same per-scale fingerprint and shape gates and the + * same budgets. What differs is what each one's cost is a function of, because + * that decides which arm can actually fail for it: + * + * - swift: `getSwiftModuleIndex` buckets a file under EVERY interior + * directory segment, so `Sources/Models/User.swift` answers to `Sources` + * and to `Models`. A miss is a Map miss and flat; a HIT returns the whole + * module bucket minus the importer, so its cost is the BUCKET size. Nothing + * in the unique layout produces a large bucket, which is why its collide + * arm is four modules instead of `dirs` of them (`SWIFT_COLLIDE_MODULES`); + * measured 3.28 there against 0.90 on file count. + * - rust: probes candidate paths with `allFilePaths.has(...)` and never + * searches, so its cost is O(path SEGMENTS) and is provably flat in the + * file count — measured 1.10 scaling, 1.06 collide scaling. That flatness + * IS the assertion, and it is why its collide arm is a deep module tree + * with ~2x the `::` segments rather than a shared-leaf layout: a collide + * arm built on file count would have been an arm that cannot fail. Note + * that `buildRustModuleIndex` lives on a DIFFERENT hook + * (`qualified-call.ts::moduleIndexFor`) and is not on this path at all. + * - python: `getPythonFileIndex` is keyed and flat on both file count and + * bucket cardinality (1.11 / 1.10), but `hasRepoCandidate` and + * `resolveAbsoluteFromFiles` each rebuild one ancestor prefix per directory + * component of the IMPORTER, so per-import cost is quadratic in path depth: + * measured depth_ratio 7.39, by far the largest here, and the reason its + * depth budget is 11 rather than the ~2 most languages carry. + * - javascript, typescript, vue: one resolver (`resolveTsModule`) behind + * three adapters, so the three corpora are the same shape and differ only + * in what actually differs — the extension list (`.js` vs `.ts`) and which + * config leg the arm exercises (`tsBaseUrlConfig` vs `vueTsconfig`). All + * three are miss-dominated bare specifiers. + * + * What they measure CHANGED with #2953. The leg used to be `suffixResolve`, + * a repo-wide search for a path ending in the specifier; these three no + * longer have it, and resolve only against a declared tsconfig mapping or a + * package manifest. Two consequences the numbers show: + * + * - the arms need a `resolutionConfig` to resolve anything at all. With + * none they all reported `resolved: 0` — every import correctly + * external — while still printing a clean scaling ratio, which is a + * bench measuring an empty branch and passing exactly like one + * measuring a full one. + * - `depth_ratio` is now structurally flat for them, and that is the + * result rather than a weakened arm: declared resolution never walks + * path components, so the `deep` arm's uniform prefix reaches the + * config (see `tsBaseUrlFor`) and its cost is the same keyed lookup the + * other arms pay. + * - c, cpp: `resolveCppImportTarget` delegates to `resolveCImportTarget`, so + * the two share a resolver and differ in extension set and in which adapter + * builds the augmented set. Cost is a basename bucket walk with a + * depth-then-lexicographic tie-break, so the collide arm (a `mod{n}` header + * in every service's `include/`) is where it grows: 2.54 / 2.64 against + * 1.06 on file count. + * + * Two properties of the corpus are load-bearing and must not be "simplified": + * + * 1. **Most imports are unresolvable.** In real source the majority of imports + * name the stdlib or a third-party package, and those run every leg of the + * cascade to completion before returning null — the fast paths never fire. + * A corpus of mostly-resolving imports measures the wrong half of the + * function and would score a reintroduced scan as linear. + * 2. **Import count scales WITH file count.** The regression is quadratic in + * `imports × files`; holding imports fixed while files grow would halve the + * exponent and let a per-import scan pass the budget. + * + * Reports per language and scale: + * - `ms`: fastest of REPS full passes, INCLUDING the one-time index build — + * hiding the build would let an index that is itself quadratic pass; + * - `scaling_ratio` `(t_large/t_small)/(LARGE/SMALL)`: ~1.0 linear, ~4.x + * quadratic at this scale gap; + * - `depth_ratio` `t_deep/t_small` at a FIXED file count with ~6x the path + * components. `scaling_ratio` divides the file count out, so it is + * scale-invariant and structurally cannot see a cost that grows with path + * DEPTH instead — and `buildSuffixIndex` (C#, Ruby, PHP, Java) emits one + * entry per component. Go, Dart, Kotlin and COBOL have depth-free indexes + * (COBOL's are keyed on the basename and nothing else), so they sit near + * 1.0; the suffix-indexed resolvers sit legitimately above 1.0, + * which is why the budget is per language. Python is the extreme and the + * reason the spread is worth a per-language number at all: its index is + * depth-free, but `hasRepoCandidate` and `resolveAbsoluteFromFiles` rebuild + * an ancestor prefix per importer directory component ON EVERY IMPORT, so + * the RESOLVER, not the index, is quadratic in depth — 7.39; + * - `collide_scaling_ratio`, the same measurement on a corpus whose + * directories SHARE their last segment and whose files share basenames — + * see the `collide` section below; + * - `heap` (all 17): retained bytes of the per-pass import index, read by + * resolving one real import — see the `heap` section below. Eight carry a + * ceiling, a floor and a ratio; the other nine carry an upper bound only; + * - a sha256 over every distinct `fromFile | target → result`, as the + * correctness gate. The tie-break-level proof that this PR's index + * reproduces the scans lives in + * `test/unit/scope-resolution/import-target-index-parity.test.ts`, which + * diffs against verbatim copies of the pre-change implementations; this + * fingerprint is the forward guard that keeps the output pinned from here. + * Every scale's fingerprint is asserted, not just `large`'s: the arms + * differ only in layout and padding, so a per-scale-only defect (a resolver + * bug that corrupts deep paths, or a corpus edit that quietly deletes the + * depth padding) moves no asserted count and would otherwise print PASS. + * + * `--check` adds arms that no ratio can carry: + * - the corpus SHAPE (files, imports, resolved, distinct outcomes, per scale), + * so a future edit cannot quietly shrink the corpus below the sizes that + * make the timing arms meaningful and still print PASS. The `deep` and + * `collide` arms must also resolve exactly what `small` resolves — padding + * and re-layout were supposed to change path depth and directory naming and + * nothing else; + * - `deep.fingerprint !== small.fingerprint` and + * `collide.fingerprint !== small.fingerprint`, so those two arms' EFFECT is + * pinned rather than only their output. Both are count-neutral by + * construction, so neutering either one (`DEEP_PAD = 0`, a `collideDir` + * that forwards to `uniqueDir`) leaves every asserted number untouched; + * comparing the arms to `small` is the only thing that notices; + * - `small_ms_ceiling` and `collide_ms_ceiling`, ABSOLUTE bounds, because a + * constant-factor regression that grows both scale arms equally passes + * every ratio; + * - a heap FLOOR beside every heap ceiling, and a presence check in front of + * every timing budget. Both exist because the same failure has now happened + * twice in this file's short life: an arm that stops measuring passes. A + * lazy `buildSuffixIndex` made four heap arms read 0 B, and 0 B is under + * every ceiling; a deleted budget key makes `got > undefined` false, which + * is a deleted gate wearing a passing arm's clothes; + * - an INVENTORY arm against `SCOPE_RESOLVERS` itself. `LANG_REGISTRY` claims + * to cover every registered resolver; this is what makes the claim true + * rather than commented, and it is the arm that would have caught PR #2911's + * language shipping unmeasured. + * + * SCOPE OF THE "independent of corpus size" CLAIM — the `collide` arm. + * `small`/`large`/`deep` mint one directory name per index (`src/pkg7`, + * `src/Ns7`, `lib/feature7`) and one basename per file, so every index bucket + * in them holds exactly ONE entry: measured, max last-segment bucket = 1 and + * max matching directories = 1 for go and csharp at both 400 and 1600 files, + * max basename bucket = 1 for dart and ruby. Bucket cardinality is the only + * non-constant term the new indexes have, so those arms certify the headline + * claim on the one shape where that term cannot appear. `collide` is the same + * workload — identical file, import and resolved counts — laid out the way + * these languages are actually written: `svcN/internal/`, `SrcN/Models/`, a + * `mod0.dart`/`mod0.rb` in every package. Measured on that shape the per-import + * cost is NOT corpus-size-independent for the four resolvers that scan a + * bucket: + * + * - go, csharp and java walk `PackageDirIndex.dirsByLastSegment[seg]`, which + * now holds every directory; + * - dart walks its basename bucket, which now holds every same-named file; + * - ruby, kotlin, php and cobol answer from keyed maps and are collision- + * IMMUNE, so their collide budgets are the linear ones — that immunity is + * the assertion, and for cobol the arm is also the only one that reaches + * the copybook-over-source tier tie-break, which needs one bookname to name + * two files; + * - csharp_csproj runs the OTHER way: its shared leaf collapses + * `dirsByLastSegment` to a single key, which makes the slash-free sweep + * (see `CSPROJ_CONFIGS`) cheaper on the collide layout than on the unique + * one, so its expensive scale arm is `large`, not `collide_large`; + * - of the eight added later, swift (3.28) and c/cpp (2.54/2.64) are the two + * that scan a bucket, and they scan DIFFERENT buckets: swift's is the + * module's own file list, which it returns, and C's is the basename bucket + * its suffix fallback walks. python answers from keyed maps and sits at + * 1.03-1.10, and javascript, typescript and vue answer from a declared + * config (#2953) — so all four keep the linear budget and that immunity is + * their assertion, exactly as for ruby and kotlin; + * - rust's collide arm is the one that is NOT a shared-leaf layout, and the + * reason is in the list above: file count is not an axis its cost has, so a + * shared-leaf rust arm would have been an arm that cannot fail. Its collide + * corpus is a deep module tree whose targets carry ~2x the `::` segments, + * which is the axis that CAN grow; the ratio across file counts staying at + * 1.06 on it is the assertion, and `collide_ms_ceiling` bounds the absolute + * cost of the long-path probe. + * + * This is a scope-of-claim limit, not a regression: on the MISS path with a + * shared leaf name the bucket grows with the file count BY CONSTRUCTION, and + * the indexed code is still faster there than the pre-change full scan. The arm + * exists so the real shape is measured and pinned, and so nobody reads the 1.8 + * budget as covering it. Narrowing it would mean a reversed-path prefix-range + * structure, which trades against the O(files × depth) memory + * `package-dir-index.ts` cites #2649 to avoid — a design change, not a tune. + * + * MEMORY — the `heap` arm. C#, Ruby, PHP and Java all resolve through the + * shared `WorkspaceFileIndex`, and `buildSuffixIndex` under it emits maps at + * O(files × depth): exactly the profile `package-dir-index.ts` cites #2649 to + * avoid for itself. That is why this is gated rather than noted — all four + * retained NOTHING across imports at BASE. C#'s `getWorkspaceFileIndex` was + * reached only from the csproj branch while the no-csproj leg scanned the Set; + * PHP and Java scanned on every leg; Ruby rebuilt and discarded a suffix index + * per `require`. Every other arm here is time or count, and no ratio can see a + * footprint. Measured in ABSOLUTE bytes, not only as a ratio: the finding is + * about the footprint itself, and a ratio alone hides a large constant. + * + * Four more are gated for the same reason as those four. JavaScript is the + * clearest case in the file: before PR #2911 it retained NOTHING because it + * built no index at all, and it now retains 25.51 MiB at 32 000 files through + * the + * same `buildSuffixIndex`. `csharp_csproj` is the newest and the one that + * proves the arm's design: same corpus and same `getWorkspaceFileIndex` as + * `csharp`, but its csproj leg asks all three questions instead of one, and it + * retains 70.29 MiB against C#'s 28.48. Python's `getPythonFileIndex` + * (9.88 MiB) and C's basename map (9.55 MiB) are an order of magnitude smaller + * but are the only structure either language keeps, and both are one careless + * edit — a stored `split('/')` array instead of a depth NUMBER — away from the + * O(files × depth) shape this arm exists to catch. + * + * WHAT THE ARM MEASURES IS NOW THE READ PATTERN, and that is the correction + * this file most needed. `buildSuffixIndex`'s two suffix maps became lazy + * (#2903 extended past `dirMap`), and the four original arms — which called + * `getWorkspaceFileIndex(set)` directly and read `index.all.length` — stopped + * asking any suffix question, built no map, and reported 0 B at 32 000 files. + * 0 B is under every ceiling, so `--check` PASSED with four gates that had + * become ceilings over nothing. Every arm now resolves one real MISSING import + * through the real resolver, so the maps it forces are the maps production + * forces; `HEAP_PROBE_TARGET` and `retainedPassBytes` carry the details, and + * `heap_floor_fraction` is the gate that would have caught the 0 B. + * + * EVERY LANGUAGE IS MEASURED, and the eight-entry list this arm ran on is now + * the BUDGET tier rather than the measurement tier. That list — `HEAP_LANGS`, + * now `HEAP_BUDGETED` — was reconciled bidirectionally against its two budget + * maps and every entry had to produce a reading, but nothing tied it to the + * property it stood for, "the languages that retain a per-pass index". Its two + * neighbours in this file do not have that gap: `LANG_REGISTRY` is reconciled + * against `SCOPE_RESOLVERS.keys()` and `CONTEXT_LANGS` against hook arity, both + * directions, both derived. Nine languages were excluded on readings taken once + * and written into this prose, and the paragraph below states the re-entry + * condition ("if any of the four ever diverges in what it ASKS, it earns an arm + * the same way") with nothing watching for the divergence. + * + * Re-measured — all seventeen, five runs each, one probe per language through + * the same `retainedPassBytes` — the prose was wrong in three separate ways: + * + * 1. THREE OF THE NINE HAD NO STATED REASON AT ALL. The old paragraph opened + * "SIX of the seventeen are deliberately NOT in HEAP_LANGS" against a list + * of eight, so go, dart and kotlin were excluded silently. All three + * retain a real per-pass structure: go's `PackageDirIndex` reads + * 2 998 464 B, dart's basename buckets 7 834 200 B, and kotlin's + * `suffixByStem` cascade 42 802 456 B (40.82 MiB) — above ruby's 39.12 and + * java's 33.34, both of which carry a full budget. (Read 48 073 096 B when + * this paragraph was written and described as "the second-largest reading + * in this file", which it was not even then: csharp_csproj and php both + * read higher. #2881 then compacted kotlin's `dirChildren` buckets and + * took 11% off it.) + * 2. TWO OF THE STATED REASONS NO LONGER HOLD. swift was excluded as "below + * its own noise floor" on 0.98 MB at 8000 files against 0.29 MB at 32 000; + * it now reads 969 120 B and 3 449 216 B, growing the right way. COBOL was + * excluded "for the same reason" on 0.54 MB then 0 B; it now reads + * 536 264 B and 2 320 456 B, ratio 1.082. Neither number moved because + * either index changed — the ARM changed, twice, when it started resolving + * a real import (#2903) and when `measureHeap` began flattening its + * corpus. Both re-measure to within 0.24% peak-to-peak over five runs, + * which is not a noise floor. + * 3. THE PROSE HAD GONE STALE AGAINST ITSELF. It quoted javascript at + * 46 208 832 B four paragraphs after quoting it at 25.51 MiB + * (26 745 296 B), because one number was re-taken with the arm and the + * other was only ever written down. + * + * Only rust's exclusion survived unchanged: 16 B at 8000 files and 16 B at + * 32 000, identical in all five runs, because it probes candidate paths with + * `allFilePaths.has(...)` and builds nothing. + * + * So the nine are still not BUDGETED — their ceilings, floors and ratio arms + * are not this change to write — but they are all measured and all bounded. See + * `HEAP_BOUNDED` for the gate and `_heap_bound_note` in baselines.json for each + * language's reading and its own reason, which are not one reason: rust builds + * nothing; go, dart, kotlin, swift and cobol build something this file has + * never bounded; and typescript, vue and cpp are duplicates of a BUILDER and of + * a READ PATTERN, both halves of which have to hold — `csharp_csproj` was + * excluded on the first half alone, at +20.8% of the C# index, and reads 2.47x + * of it now that the second half decides the number. Measured here: typescript + * 26 745 296 B against javascript's 26 745 296 B (byte-identical in four runs + * of five), cpp 10 023 344 B against c's 10 018 816 B (+0.05%), vue + * 28 884 016 B (+8.0%, what `.vue` instead of `.ts` buys on two thirds of the + * paths). The bound is what watches for the divergence the re-entry condition + * names — and it watches at 1.5x, so it catches a language GROWING an index, + * not a duplicate drifting by 8%. That limit is stated rather than papered + * over: the tight form is a same-process ratio against the arm each one + * duplicates, which is the only form immune to the cross-runner heapUsed drift + * an absolute bound has to tolerate. + * + * KNOWN BLIND SPOT, measured: a full workspace scan reintroduced on 1-in-32 + * imports passes every arm here (dart scored 1.458 scaling, 1.736 ms). The gate + * that NARROWS it is not a timing gate — the parity test above counts + * iterations of the file-set Set and reads 14 instead of 1 for that same + * mutation. It does not CLOSE it: the counter watches the Set, while the + * resolvers hold materialized arrays of the same file list + * (`WorkspaceFileIndex.normalized`/`.all`, Dart's basename buckets, + * `PackageDirIndex.filesByDir`, PHP's `filesByRawDirectory`, COBOL's two tier + * maps), and a 1-in-32 scan over one of THOSE passes + * both the parity test and `--check`. Chasing it by tightening these ceilings + * toward the noise floor would only buy flaky CI; see `_blind_spot` in + * baselines.json. PR #2911 is the proof that this blind spot is real rather + * than theoretical: JavaScript's missing index was a scan of + * `ImportPassCache.normalizedFileList` on EVERY import, which the Set counter + * could not see, and it took a differential parity test over 211 200 pairs plus + * this bench's arrival to pin it. + * + * THE FIFTH ARGUMENT — `context`, and exactly how much of it is measured. This + * harness used to call the inner resolvers with THREE arguments while `run.ts` + * calls `provider.resolveImportTarget` with FIVE, the fifth being + * `{ parsedFiles, parsedImport }`. Every arm was therefore a measurement of a + * call shape production never makes, and that is not a cheap thing to get + * wrong: defeating the `perFileSet` memo behind PHP's `filesByDirectory` + * measures 197.0 µs -> 9976.2 µs per import (50.6x) with every test still + * green, and nothing here could see it. + * + * `resolveOne` now makes the production call. Four of the seventeen arms can + * observe it — PHP, Java, Kotlin and Python declare a fifth parameter — and + * that is ASSERTED rather than asserted-in-a-comment: the + * inventory arm at the foot of the file reads + * `SCOPE_RESOLVERS.get(language).resolveImportTarget.length` and reconciles it + * against `CONTEXT_LANGS` in both directions, so a language that grows a + * context leg cannot ship with the leg unmeasured. The other thirteen are handed + * nothing and build no `ParsedFile[]` at all, so their numbers are unmoved. + * + * `newPass` mints the `ParsedFile[]` FIRST and derives the path set from it + * (`new Set(parsedFiles.map(f => f.filePath))`), because that is what `run.ts` + * does — two independently built lists are a shape the pipeline cannot produce + * and would let the two memos disagree about which files exist. Both are fresh + * per pass for the reason the Set always was: `filesByDirectory` (PHP) and + * `parsedFileByPath` (Python) are `perFileSet` memos keyed on the ARRAY's + * identity, so a reused array would hide their build from rep 2 onward and + * `fastest()` takes the minimum. + * + * THE LEGS ACTUALLY RUN, which is what a "context is threaded" claim is worth + * nothing without — a leg that returns early measures nothing, the exact + * failure the four 0 B heap arms already demonstrated in this file. PHP's needs + * `parsedImport.kind` to be `named` or `alias` AND `importedSymbolKind` to be + * `function` or `const`; Python's needs a `named`/`alias` import too, because + * the synthetic `namespace` spelling this file used to pass makes + * `pythonImportedSubmoduleTarget` return null and `context.parsedFiles` is then + * never read at all. A deterministic `context` arm pins both per language: a + * three-file corpus resolved through `resolveOne` twice, once with the pass's + * `parsedFiles` and once without, whose two answers must DIFFER and must both + * equal what baselines.json records. Dropping the fifth argument, dropping + * `importedSymbolKind`, or reverting Python to `namespace` collapses the two + * onto one value and fails. PHP and Python still agree with their fallback on + * the main corpus; Java and Kotlin deliberately have no context-free fallback, + * so their fingerprints pin the declared-package answers recorded here. + * + * WHAT IS STILL NOT MEASURED, narrowed rather than deleted: + * + * - Python's `parsedFileByPath` memo is exercised by the five timing arms and + * NOT by the heap arm, and structurally cannot be. `retainedPassBytes` + * requires its probe to MISS, while every path that builds that memo runs + * through a non-null `packageTarget` which `resolvePythonImportTarget` then + * returns. So nothing here bounds that Map's footprint; it is one pointer + * per parsed file, O(files) with no depth term, and the count gate in + * import-target-index-reuse.contract.test.ts is what holds it to one build + * per pass; + * - PHP runs with the Composer PSR-4 configuration every production project + * supplies. Configured hits and unmatched dependency misses share one + * workload, so the Composer gate cannot become an unmeasured fast path; + * - the `const` tail of PHP's leg (`candidateFiles.length === 1`) is a + * different ANSWER, not a different cost: `function` runs the identical + * candidate gather and `localDefs` filter and diverges only in the last two + * lines. It is gated by count in the contract test above. + * + * COST, and the honest version of it. REPORT mode is ~33-35 s, down from ~46 s: + * the timing phase fell from 39.8 s to 28.7 s when `REPS` became per-language + * (see `repsFor`), and that win is real. `--check` is ~44-45 s, which is + * ESSENTIALLY UNCHANGED from the ~46 s it cost before, because the inventory + * arm added here loads `pipeline/registry.ts` and that one dynamic import + * consumes almost the whole `repsFor` win — measured 6.3-6.5 s on one box and + * 9.3-10.0 s on another, in isolation and after this file's own static imports + * are already resident. Do not read the two modes as "~46 → ~42": only report + * mode got faster. + * + * MEASURING ALL SEVENTEEN HEAP ARMS instead of eight costs 1.37 s, and that is + * a measured number rather than the "seconds are free here" the paragraph below + * would have let it be. Timed per language with the phase instrumented, twice: + * the heap phase goes 2.06 s -> 3.43 s (1.377 s and 1.370 s added over the two + * runs). Those figures predate #2960: Kotlin now retains a compact + * declared-package/module-binding index rather than three path-suffix maps. + * End to end + * that is report mode 33.76 s -> 34.93 s (min of three runs each, +1.17 s, + * consistent with the phase measurement inside run-to-run noise). `--check` was + * 41.60 s before and reads 41.48-43.56 s after, i.e. the whole-run difference + * is INSIDE the registry import's own 6.3-10.0 s spread and cannot be resolved + * at that level — the +1.37 s phase number is the one to quote. + * + * That cost was weighed and KEPT, on the one number that decides it: the + * `benchmarks` job is not CI's critical path. On the last green run of main it + * finished in 9 m 23 s against 12 m 58 s for the sharded coverage job that + * gates the merge, so ~4 m 40 s of slack sits above this bench and ten seconds + * of it buys zero merge latency. Moving the arm into a vitest file would move + * the registry load ONTO that critical path, and would weaken it besides: from + * `LANG_REGISTRY`'s `SupportedLanguages` values, which are what the five + * dispatcher branches key off, down to baselines.json's arm NAMES plus a + * hand-written rule for de-aliasing `csharp_csproj`. See the wall-clock note in + * `_arms_note` for the per-language breakdown and for what to drop first if + * that stops fitting the job. + * + * Run: + * node --expose-gc --import tsx bench/import-target/measure.mjs # report + * node --expose-gc --import tsx bench/import-target/measure.mjs --check # CI gate + */ +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { fileURLToPath } from 'node:url'; + +import { SupportedLanguages } from 'gitnexus-shared'; + +import { resolveGoImportTarget } from '../../src/core/ingestion/languages/go/import-target.ts'; +import { resolveDartImportTarget } from '../../src/core/ingestion/languages/dart/import-target.ts'; +import { resolveRubyImportTarget } from '../../src/core/ingestion/languages/ruby/import-target.ts'; +import { resolveCsharpImportTarget } from '../../src/core/ingestion/languages/csharp/import-target.ts'; +import { kotlinScopeResolver } from '../../src/core/ingestion/languages/kotlin/scope-resolver.ts'; +import { resolvePhpImportTargetInternal } from '../../src/core/ingestion/languages/php/import-target.ts'; +import { javaScopeResolver } from '../../src/core/ingestion/languages/java/scope-resolver.ts'; +import { cobolScopeResolver } from '../../src/core/ingestion/languages/cobol/scope-resolver.ts'; +import { resolveSwiftImportTarget } from '../../src/core/ingestion/languages/swift/import-target.ts'; +import { resolveRustImportTarget } from '../../src/core/ingestion/languages/rust/import-target.ts'; +import { resolvePythonImportTarget } from '../../src/core/ingestion/languages/python/import-target.ts'; +import { makeJsResolveImportTarget } from '../../src/core/ingestion/languages/javascript/import-target.ts'; +import { makeVueResolveImportTarget } from '../../src/core/ingestion/languages/vue/import-target.ts'; +// The two `ScopeResolver`s, not their inner resolvers — see `RESOLVE_HOOK`. +import { typescriptScopeResolver } from '../../src/core/ingestion/languages/typescript/scope-resolver.ts'; +import { cScopeResolver } from '../../src/core/ingestion/languages/c/scope-resolver.ts'; +import { cppScopeResolver } from '../../src/core/ingestion/languages/cpp/scope-resolver.ts'; +// `SCOPE_RESOLVERS` is NOT imported here — see the inventory arm at the bottom, +// which loads it dynamically. Statically it costs 6-10 s of module load +// depending on the box (measured both ways there), because reaching the +// registry pulls in every registered provider and everything under them, and it +// is wanted by one `--check` arm that runs after the last measurement. + +/** The JS and Vue adapter FACTORIES return a closure; the memo they read is + * module-level, so one instance per process is both correct and what the + * registry does (`resolveImportTarget: makeJsResolveImportTarget()`). */ +const jsResolveImportTarget = makeJsResolveImportTarget(); +const vueResolveImportTarget = makeVueResolveImportTarget(); + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const BASELINE_PATH = path.resolve(__dirname, 'baselines.json'); + +const SMALL = 400; +const LARGE = 1600; +const IMPORTS_PER_FILE = 8; +/** Extra directory components prepended in the `deep` arm — see `depth_ratio`. */ +const DEEP_PAD = 16; +/** + * `fastest()` below is a min-of-N estimator, so N is the noise knob: raising it + * lowers and stabilises the minimum. `depth_ratio` divides two sub-3 ms + * measurements, and Dart's are sub-1 ms, so it is by far the noisiest number + * here. Measured over 22 `--check` runs on an idle box: at N=5 it tripped its + * own budget ~1 run in 20, at N=7 Dart still swung 3.0x peak-to-peak and + * tripped once. N=15 (which matches `bench/cfg`, `bench/schema-pairs` and + * `bench/callable-value-flow`) collapsed every language to a 1.13-1.26x swing + * with 22/22 passing; the distributions are recorded in `_arms_note`. + * + * N used to be 15 for EVERY arm, set globally by the noisiest cell. That paid + * the noisiest cell's insurance premium on cells a thousand times its size: + * the recorded overshoot of min-of-K against min-of-15 is a function of the + * cell's absolute duration, not of the language — 31.8% on `swift.small` + * (0.43 ms) and 37.6% on `dart.collide` (1.5 ms) at the extreme, but at most + * 6.3% at K=7 for every cell at or above 10 ms. + * + * So N is picked PER LANGUAGE, from the cost of its cheapest arm: 15 while that + * is under `REPS_CHEAP_MS`, and `~REPS_BUDGET_MS` worth of samples above it, + * floored at `REPS_MIN`. Per language rather than per cell so all five arms of + * a language share one estimator and the four ratios stay comparisons of like + * with like. In practice that is still 15 for go, csharp, dart, kotlin, java, + * cobol, swift, rust, python, c and cpp — every language the flakiness above + * was ever about — and 7-8 for php, csharp_csproj, ruby, javascript, typescript + * and vue, whose cheapest cell is 20-28 ms. Replayed against two independent + * runs' sample sets it saved 12.8 s and 12.4 s of a 46 s run with all 85 cells + * passing all five gates at 0.4-0.7 of budget, and min-of-7 reads slightly + * HIGHER than min-of-15, so the gates get marginally more sensitive rather than + * less. The chosen N is reported per language as `reps`. + */ +const REPS_MAX = 15; +const REPS_MIN = 7; +/** Sampling budget per cell for the languages that do not get `REPS_MAX`. */ +const REPS_BUDGET_MS = 150; +/** Below this a cell is small enough for the min-of-N estimator itself to be + * the dominant error, so it gets the full `REPS_MAX` regardless of budget. The + * nearest language on either side of it is 3.2 ms and 20.0 ms, so nothing sits + * near the boundary. */ +const REPS_CHEAP_MS = 5; +const WARMUP = 2; + +/** N for one language, from one warmed pass of its cheapest arm. */ +function repsFor(probeMs) { + if (probeMs < REPS_CHEAP_MS) return REPS_MAX; + return Math.min(REPS_MAX, Math.max(REPS_MIN, Math.ceil(REPS_BUDGET_MS / probeMs))); +} + +/** Heap arm. Far more files than the timing arms because the finding + * is an ABSOLUTE footprint at repository scale, and 1600 files would report a + * fraction of a MiB — a number no ceiling could usefully bound. `HEAP_PAD` + * keeps the paths at a plausible monorepo depth: `buildSuffixIndex` is + * O(files × depth), so a flat corpus would understate it by ~4x. */ +const HEAP_SMALL = 8000; +const HEAP_LARGE = 32000; +const HEAP_PAD = 8; +/** The languages whose retained per-pass index carries a BUDGET — a ceiling, a + * floor derived from `heap_reading_bytes`, and the linear-growth ratio arm. + * All arms are measured through `retainedPassBytes`, one real import through + * the real resolver; this list decides which GATE a reading gets, not whether + * it is taken. The configured C# arm stays here because its read pattern + * reaches retained structures that the unconfigured arm cannot observe. + * + * The remaining three are `HEAP_BOUNDED`, DERIVED from this list rather than + * written beside it, and they carry an upper bound and NO floor. That asymmetry + * is the point: a bound catches "this language grew an index", which is the + * re-entry condition, while a floor over a reading at or below its own noise + * would gate the noise. rust reads 16 B at both scales; swift's ratio is 0.888 + * and cobol's 1.082, both outside the linearity every budgeted arm shows, so a + * floor and a ratio arm would be measuring the measurement. See the MEMORY + * section of the header for what re-measuring the full inventory found. */ +const HEAP_BUDGETED = [ + 'csharp', + 'csharp_csproj', + 'ruby', + 'php', + 'java', + 'python', + 'c', + // Promoted once every language was actually measured. Each retains a real + // per-pass structure and each grows LINEARLY with the file count (ratio + // 0.996-1.004 against a 1.25 budget over 8000 -> 32000 files), so each can + // carry the full ceiling + floor + ratio set rather than a bound alone. + // Kotlin now retains its declared-package/module-binding index. Its measured + // 32000-file reading and standard 1.5x ceiling are recorded in baselines.json. + 'kotlin', + 'dart', + 'go', + 'cpp', +]; +// javascript, typescript and vue were budgeted here until #2953 and are now +// BOUNDED, which is a demotion in gate strength and a promotion in what the +// number means. They retained ~26.7 MiB each because they built a per-pass +// `SuffixIndex` over the whole file list; they no longer build one at all, +// because declared resolution derives nothing from the file set — a candidate +// comes from a tsconfig mapping or a manifest and is checked with one +// `Set.has`. The readings are 0-16 B. +// +// A floor over a reading at or below its own noise gates the noise, which is +// the same reason rust sits in this tier at 16 B — so they take a bound and no +// floor. The bound is what still matters: it catches these three growing an +// index again, which is the re-entry condition for the cost #2911 and #1918 +// were about. + +/** + * The arms handed the fifth `context` argument — `{ parsedFiles, parsedImport }` + * — because their registered hook DECLARES it. Four of seventeen arms, and the + * inventory arm at the foot of this file reconciles that claim against + * `SCOPE_RESOLVERS` in both directions rather than trusting this line. + * + * These are also the only arms for which `newPass` builds a `ParsedFile[]` at + * all. Building one for the other thirteen would cost their timed loop an + * O(files) allocation per pass that no resolver of theirs can even observe — + * their hooks declare three or four parameters — so their numbers stay exactly + * where they were. + */ +const CONTEXT_LANGS = ['php', 'java', 'kotlin', 'python']; + +/** + * Needs `node --expose-gc` to force collection for a clean delta; without it + * the heap metric is reported as null and its `--check` gate would be skipped, + * which is why `--check` refuses to run without the flag (see below). + * + * TWO cycles because the value is a `WeakMap`'s: the first clears the entry + * once its key is unreachable, the second collects what the entry held. That is + * not always enough — PHP reaches the shared index through a second per-file- + * set memo of its own (`getPhpWorkspaceIndex` wraps `getWorkspaceFileIndex`, + * both keyed on the same Set) and that chain measured FOUR cycles to release, + * with two leaving 9.3 MB of the previous read still counted live. The answer + * to that is `HEAP_RETAINED`, which removes the need to release anything inside + * a measurement window, plus the deeper drain `measureHeap` runs between + * languages where a late free costs nothing. Cycles are not the knob: with + * `HEAP_RETAINED` in place, two and four produce byte-identical readings, and + * four cost 4.5 s of wall clock over a retained heap this size. + */ +const GC = typeof global.gc === 'function' ? () => (global.gc(), global.gc()) : null; + +/** Deterministic 32-bit avalanche (murmur3 finalizer) — no `Math.random()`, so + * the corpus and therefore the fingerprint are byte-reproducible. */ +function mix(n) { + let x = n >>> 0; + x = Math.imul(x ^ (x >>> 16), 0x85ebca6b) >>> 0; + x = Math.imul(x ^ (x >>> 13), 0xc2b2ae35) >>> 0; + return (x ^ (x >>> 16)) >>> 0; +} + +const GO_MODULE = { modulePath: 'example.com/mod' }; +/** + * The `csharp_csproj` arm's project configs — the whole reason that arm exists. + * + * `csharp` builds its context with NO `csharpConfigs`, so every one of its + * imports takes the no-csproj branch and the csproj leg's namespace-directory + * index (#2902) would ship unmeasured. Two configs rather than one because the + * leg's cost is a function of `dirPrefix`'s SHAPE, and one config cannot + * produce all three: + * - `App` + `projectDir: 'src'` gives `dirPrefix = 'src/'`, which + * CONTAINS a slash, so `candidateDirs` answers from the last-segment bucket; + * - `Lib` + `projectDir: ''` gives `dirPrefix = ''`, slash-FREE, the + * one leg that sweeps the last-segment KEYS and so is not constant-time; + * - `Lib` itself (the import IS the root namespace, no `projectDir` to stand + * in) gives an EMPTY `dirPrefix`, answered from `singleSegmentDirs`. + * All three were a full `normalizedFileList` pass per import before #2902. + */ +const CSPROJ_CONFIGS = [ + { rootNamespace: 'App', projectDir: 'src' }, + { rootNamespace: 'Lib', projectDir: '' }, +]; +/** + * The `resolutionConfig` the ts-family arms thread (#2953). + * + * These three used to run with `tsconfigPaths: null` for javascript and + * typescript and an alias map for vue, because the leg being measured was + * `suffixResolve` — a repo-wide search for a path ending in the specifier, + * which needs no configuration to answer and answered even when nothing + * declared the import. #2953 deleted that leg for the ts family: a specifier + * now resolves only against a declared tsconfig mapping or a package manifest. + * + * With no config, therefore, all three arms resolve NOTHING — every import is + * correctly external — and the bench measures an empty branch while reporting a + * perfect scaling ratio. A bench that measures nothing passes exactly like one + * that measures something, so each arm is given the config its corpus is + * spelled for, and the two configs cover the two legs the new resolver has: + * + * - `TS_BASE_URL` — `baseUrl` at the repo root, so `src/mod3/file7` resolves + * the way a `baseUrl` project's absolute import does. Used by javascript and + * typescript. + * - `vueTsconfig` — a `paths` PATTERN, which is a different branch: + * longest-prefix selection and `*` substitution, then a candidate probe per + * target. Every local Vue import below is spelled `@/…`, so the vue arm + * stays a third measurement rather than a third copy — the same role it had + * before, now against the branch that replaced the alias rewrite. + * + * Not covered here: the workspace-manifest leg + * (`node-workspace-packages.ts`), which is a `Map.get` on a package name and + * does not scale with the file set. + */ +const tsBaseUrlConfig = (baseUrl) => ({ + tsconfigs: { scopes: [{ dir: '', baseUrl, paths: [] }] }, + nodeWorkspacePackages: null, +}); +const vueTsconfig = (baseUrl) => ({ + tsconfigs: { + scopes: [ + { dir: '', baseUrl, paths: [{ pattern: '@/*', targets: [joinBase(baseUrl, 'src/*')] }] }, + ], + }, + nodeWorkspacePackages: null, +}); +const joinBase = (baseUrl, rest) => (baseUrl === '' ? rest : `${baseUrl}/${rest}`); +/** + * The `deep` arm prepends a UNIFORM `d0/…/d15/` prefix to every path + * (`buildFiles`), and the import spellings do not change. Under the old suffix + * matcher that was the point: the resolver walked path components, so depth was + * the cost. Declared resolution never walks — the config names an exact base — + * so the prefix has to reach the config or the whole arm resolves nothing and + * measures the miss path at depth instead of the hit path at depth. + */ +const tsBaseUrlFor = (pad) => + pad === 0 ? '' : Array.from({ length: pad }, (_, n) => `d${n}`).join('/'); +const phpComposerConfigFor = (pad) => ({ + psr4: new Map([['App', joinBase(tsBaseUrlFor(pad), 'src/App')]]), + authoritativePsr4: new Set(['App']), +}); +const renderPhpComposerConfig = (config) => + [...config.psr4] + .map(([namespace, directory]) => `${namespace || ''}=${directory || ''}`) + .sort() + .join(';'); +/** Keyed by LAYOUT name, so there is no `csharp_csproj` row: `buildFiles` + * aliases that arm to `csharp` before this table is read. */ +const EXTENSION = { + go: '.go', + csharp: '.cs', + dart: '.dart', + ruby: '.rb', + kotlin: '.kt', + php: '.php', + java: '.java', + cobol: '.cbl', + swift: '.swift', + rust: '.rs', + python: '.py', + javascript: '.js', + typescript: '.ts', + vue: '.vue', + c: '.c', + cpp: '.cpp', +}; +/** C and C++ resolve `#include` against HEADERS, which reach the resolver + * through `resolutionConfig` rather than through `allFilePaths` — see + * `newPass`. Half of each corpus is headers; this is their extension. */ +const HEADER_EXTENSION = { c: '.h', cpp: '.hpp' }; +/** Directory fan-out. Shared because `buildRepo`'s collide targets address + * files by `j % dirs` / `Math.floor(j / dirs)` and must agree with the layout + * `buildFiles` produced. */ +const dirsFor = (fileCount) => Math.max(4, Math.floor(fileCount / 8)); +/** Swift's collide arm, and the ONLY place a bucket size is pinned by a + * constant rather than by `dirsFor`. A module bucket is what Swift returns, so + * its cardinality has to grow with the corpus for the arm to measure anything: + * four modules means fileCount/4 per bucket (100 at `collide`, 400 at + * `collide_large`), which is the shape a small SPM package actually has. */ +const SWIFT_COLLIDE_MODULES = 4; +/** File stems follow each language's own naming convention, because C#'s and + * PHP's suffix maps carry a case-insensitive tier and a lower-cased corpus + * would leave it answering the same question twice. Keyed by LAYOUT name, like + * `EXTENSION` — no `csharp_csproj` row, for the same reason. */ +const PASCAL_CASE_FILES = new Set([ + 'csharp', + 'kotlin', + 'php', + 'java', + 'cobol', + // Swift types and Vue SFCs are PascalCase by universal convention. + 'swift', + 'vue', +]); +/** Rust and Python name a DIRECTORY as a module through a well-known file, so + * the first file minted in each directory is that file rather than a numbered + * one. Every in-repo target below resolves to one of them. */ +const PACKAGE_STEM = { rust: 'mod', python: '__init__' }; + +/** + * The end of a per-language dispatcher, where five of them used to fall through + * to a bare `return`. + * + * Four of those fallthroughs meant "ruby" and the fifth meant "csharp". So + * `ruby` appeared nowhere in this file except `EXTENSION` and the language + * list, and — the part that matters — a language added to the list but missed + * in the dispatchers would have been benchmarked as RUBY'S CORPUS RESOLVED BY + * C#'S RESOLVER: five plausible timings, a stable fingerprint, and a permanent + * pass over a language nobody had measured. Every dispatcher now names its last + * branch and throws here instead, so the missing wiring is a crash on the first + * run rather than a green gate. + */ +function unwiredLanguage(where, lang) { + return new Error( + `bench: ${where} has no branch for '${lang}'. Every language in LANG_REGISTRY needs one in ` + + `uniqueDir, collideDir, uniqueTarget, collideTarget and resolveOne. (uniqueDir and ` + + `collideDir see the LAYOUT name, which is never 'csharp_csproj' — buildFiles aliases it ` + + `to 'csharp'.) Falling through here used to hand the language another one's corpus or ` + + `another one's resolver, and nothing in --check could tell.`, + ); +} + +/** + * UNIQUE-LEAF layout: one directory name per index, so no two directories share + * a last segment and no two files share a basename. Every index bucket holds + * exactly one entry. A nested same-name directory in one repo slice is the + * shape the first-`indexOf` tie-break used to reject (see package-dir-index.ts); + * #2881 removed that tie-break from every resolver that had it, so the go, + * csharp, java and kotlin arms all resolve their `d % 7` slice now. + * + * A repeat the query cannot ask about leaves the arm blind, which is why go's + * slice repeats the WHOLE package path: a Go import addresses `src/pkg{d}`, and + * `…/internal/pkg{d}` does not end with that, so the old rule was never even + * reached and every go arm sat still through the fix. Java, C# and Kotlin query + * the whole dotted path FIRST and only fall back to the tail through + * progressive stripping, so their slices — which repeat the last segment only — + * move through that fallback rather than the primary query. The consequence is + * measured and worth knowing: a partial revert that reinstates first-occurrence + * only for multi-segment package paths is caught on the go arm alone. + */ +function uniqueDir(lang, d, i) { + // Go's nested slice repeats the WHOLE queried path (`src/pkg{d}`), not just + // its last segment. `src/pkg{d}/internal/pkg{d}` repeated only `pkg{d}`, so + // the query `src/pkg{d}` failed on "the directory ends with the package path" + // and never reached the first-occurrence rule at all — Go's arms did not move + // when #2881 removed that rule, which would have shipped a widened bucket + // with no bench coverage while C# and Java were re-baselined for it. + if (lang === 'go') return d % 7 === 0 ? `src/pkg${d}/internal/src/pkg${d}` : `src/pkg${d}`; + // Leaf-only repeat, deliberately: this layout is shared with the + // `csharp_csproj` arm, whose configs mint `dirPrefix` against `src/Ns{d}`, so + // deepening it to the full `App/Ns{d}` query path resolves that arm to ZERO + // and breaks its same-workload invariant. C# therefore exercises the removed + // rule through progressive stripping rather than through its primary query. + if (lang === 'csharp') return d % 7 === 0 ? `src/Ns${d}/Sub/Ns${d}` : `src/Ns${d}`; + if (lang === 'dart') return d % 3 === 0 ? `lib/feature${d}` : `pkg/feature${d}`; + if (lang === 'kotlin') { + return d % 7 === 0 + ? `mod${d}/src/main/kotlin/com/example/pkg${d}/inner/pkg${d}` + : `mod${d}/src/main/kotlin/com/example/pkg${d}`; + } + if (lang === 'php') return d % 7 === 0 ? `src/App/Ns${d}/Sub/Ns${d}` : `src/App/Ns${d}`; + if (lang === 'java') { + return d % 7 === 0 + ? `mod${d}/src/main/java/com/example/pkg${d}/inner/pkg${d}` + : `mod${d}/src/main/java/com/example/pkg${d}`; + } + // COBOL resolves on the BASENAME alone (`path.basename(fp, ext)`), so its + // directories are pure realism — a copybook library beside the programs. + if (lang === 'cobol') return d % 3 === 0 ? `copybooks/grp${d}` : `src/prog${d}`; + // SPM. The nested slice makes one file's interior segments repeat + // (`Sources/Mod7/Internal/Mod7/File7.swift`), and `getSwiftModuleIndex` + // pushes once per segment, so that file appears TWICE in module `Mod7`'s + // returned list. Real layout, real output; the fingerprint pins it. + if (lang === 'swift') { + return d % 7 === 0 ? `Sources/Mod${d}/Internal/Mod${d}` : `Sources/Mod${d}`; + } + // Cargo. The nested slice has NO `mod{d}/mod.rs`, so `crate::mod{d}::thing` + // misses there — the same resolves/misses split every other unique arm has. + if (lang === 'rust') return d % 7 === 0 ? `src/mod${d}/inner` : `src/mod${d}`; + if (lang === 'python') return d % 7 === 0 ? `pkg${d}/inner` : `pkg${d}`; + if (lang === 'javascript' || lang === 'typescript') return `src/mod${d}`; + // Vue's local imports are all `@/…`, which the alias rewrites to `src/…`, so + // the whole corpus must live under `src/` for that branch to hit. + if (lang === 'vue') return `src/mod${d}`; + // C and C++ split headers from sources — the shape that makes + // `resolutionConfig` load-bearing. Odd `i` is the header. + if (lang === 'c' || lang === 'cpp') return i % 2 === 1 ? `include/comp${d}` : `src/comp${d}`; + if (lang === 'ruby') return `lib/mod${d}`; + throw unwiredLanguage('uniqueDir', lang); +} + +/** + * SHARED-LEAF layout: every directory ends in the SAME segment, so one bucket + * holds all of them. The `d % 7` slice keeps the nested same-name directory of + * the unique layout, and Go additionally replicates one package (`internal/ + * shared`) across services — the monorepo shape `filesDirectlyInPkgDir`'s merge + * exists for, and the only arm in this bench that reaches `dirCount > 1`. + * + * Each language's local import spelling is chosen so this arm resolves exactly + * as many imports as `small` does (asserted): same workload, different layout. + */ +function collideDir(lang, d, i) { + if (lang === 'go') { + // `…/sub/internal` repeats only the last segment, which the ends-with test + // answers on its own; `…/internal/sub/svc{d}/internal` is the shape the + // removed first-occurrence rule used to reject (see `uniqueDir`). + if (d % 7 === 0) return `svc${d}/internal/sub/svc${d}/internal`; + return d % 5 === 1 ? `svc${d}/internal/shared` : `svc${d}/internal`; + } + // Leaf-only repeat here too, and unlike the kotlin arm below that is not a + // blind spot — measured, base against head over this exact corpus. C#'s match + // test is an unanchored ends-with and its cascade strips leading segments, so + // `App.Src{d}.Models` reaches `Models` after two strips and finds + // `Src{d}/Models/Inner/Models`, whose FIRST `/Models/` is not its last: the + // removed first-occurrence rule rejected it and the current one takes it. The + // `csharp` collide fingerprint therefore moves across #2881 (03c9afe33276 + // head, 89d0a054b617 base) with the resolved count unchanged at 1153 — the + // arm sees the change, it just sees it as different ANSWERS rather than more + // of them. Deepening the slice to `Src{d}/Models/Inner/Src{d}/Models` only + // moves which strip level finds it; both layouts move base -> head, so it + // buys nothing here. + // + // And it costs, because the `csharp_csproj` constraint binds this arm too — + // differently from the way it binds `uniqueDir`. There, deepening resolves + // that arm to ZERO. Here it resolves MORE: `Lib` has `projectDir: ''`, so its + // `dirPrefix` is `Src{d}/Models`, which is not a segment suffix of + // `…/Inner/Models` and is one of `…/Inner/Src{d}/Models`. Measured, the + // csproj arm's collide `resolved` goes 979 -> 1153 against its `small` 979, + // which is the same-workload invariant `--check` asserts. (Worth recording + // while it is measured: with the shipped layout BOTH csproj arms are blind to + // #2881 — unique and collide fingerprints identical base and head — because + // `getFilesInDir` is keyed on segment-aligned directory SUFFIXES and neither + // nested slice is one. Closing that is the deepening plus a mirrored miss for + // the csproj arm's `d % 7` slice, i.e. a corpus redesign and four + // re-baselines, not this edit.) + if (lang === 'csharp') return d % 7 === 0 ? `Src${d}/Models/Inner/Models` : `Src${d}/Models`; + if (lang === 'dart') return `pkg${d}/lib/src`; + if (lang === 'kotlin') { + return d % 7 === 0 + ? // Repeats the WHOLE queried path (`com.example.models`), not just the + // `models` leaf. With a leaf-only repeat this arm was structurally + // blind to the #2881 rule: a full revert of the Kotlin guards left both + // collide fingerprints unmoved, because `com/example/models` is not a + // suffix of `…/models/inner/models` and the query never reached the + // rule. Deepening it is the only corpus edit in this file that buys + // coverage — the same deepening applied to the java and kotlin UNIQUE + // arms was measured and reverted, because progressive stripping lands + // those queries on the same file either way. + `mod${d}/src/main/kotlin/com/example/models/inner/com/example/models` + : `mod${d}/src/main/kotlin/com/example/models`; + } + if (lang === 'php') return `src/App/Svc${d}/Models`; + if (lang === 'java') { + return d % 7 === 0 + ? `svc${d}/src/main/java/com/example/model/inner/model` + : `svc${d}/src/main/java/com/example/model`; + } + if (lang === 'cobol') return `svc${d}/copybooks`; + // Swift's collision axis is neither a shared directory name nor a shared + // basename: `byModule` is KEYED on the module name, so what grows a bucket is + // FEWER modules holding MORE files. `SWIFT_COLLIDE_MODULES` of them, so the + // bucket a hit returns is fileCount/4 — 100 entries at 400 files and 400 at + // 1600 — and a hit copies that whole bucket minus the importer. + if (lang === 'swift') return `Sources/Mod${d % SWIFT_COLLIDE_MODULES}`; + // Rust's cost is O(path SEGMENTS), not O(files) — it probes candidate paths + // with `.has()` and never searches. So its collide arm is a deep module tree + // whose targets carry ~2x the `::` segments, which is the axis that CAN grow; + // that the ratio across file counts stays flat on it is the assertion. + if (lang === 'rust') return `src/l0/l1/l2/l3/l4/mod${d}`; + // The `inner` slice mirrors the unique arm's, and for the same reason: it is + // where the in-repo target misses, so both arms resolve the same count. + if (lang === 'python') return d % 7 === 0 ? `svc${d}/models/inner` : `svc${d}/models`; + if (lang === 'javascript' || lang === 'typescript') return `pkg${d}/src`; + if (lang === 'vue') return `src/pkg${d}/components`; + if (lang === 'c' || lang === 'cpp') return i % 2 === 1 ? `svc${d}/include` : `svc${d}/src`; + if (lang === 'ruby') return `svc${d}/lib/models`; + throw unwiredLanguage('collideDir', lang); +} + +/** + * The file paths of one synthetic repository. `dirs` grows with the file count + * so directory fan-out is realistic at both scales rather than collapsing onto + * a handful of buckets. + * + * `pad` prepends that many extra directory components to every path. Every + * path-based resolver keeps the same answer under that padding. Kotlin's + * package facts are also unchanged by it. The padding therefore changes path + * depth without changing what resolves, making `deep` a clean depth + * measurement rather than a different corpus. + * + * Split out from `buildRepo` so the heap arm can build 32k paths without also + * minting 256k import tuples it would never resolve. + */ +function buildFiles(lang, fileCount, pad, shape) { + const dirs = dirsFor(fileCount); + const files = []; + // `csharp_csproj` is `csharp` with a different CONTEXT and nothing else. The + // alias is here, in the one place that mints paths, rather than as a second + // copy of the same layout in `uniqueDir`/`collideDir`: it makes the two arms' + // corpora identical by construction, so a later edit to C#'s layout cannot + // silently desynchronize them and turn the comparison into two experiments. + const layout = lang === 'csharp_csproj' ? 'csharp' : lang; + const ext = EXTENSION[layout]; + const prefix = pad === 0 ? '' : Array.from({ length: pad }, (_, n) => `d${n}`).join('/') + '/'; + for (let i = 0; i < fileCount; i++) { + const d = i % dirs; + const dir = shape === 'collide' ? collideDir(layout, d, i) : uniqueDir(layout, d, i); + // In the collide shape Dart, Ruby, PHP and COBOL carry a REPEATED basename + // — the term their indexes bucket or key on (COBOL's two tier maps are + // keyed on the uppercased basename and NOTHING else). `i / dirs` is unique + // within a directory (8 files land in each) and identical across + // directories, which is exactly the `models.dart` / `models.rb`-in-every- + // package convention. Go, C#, Kotlin and Java bucket on the DIRECTORY + // instead, so their stems stay unique and the shared leaf segment is what + // collides for them. + const collideStem = + layout === 'dart' || + layout === 'ruby' || + layout === 'cobol' || + layout === 'php' || + // The three added later that also bucket or key on the BASENAME: + // JS/TS `buildSuffixIndex` (one entry per path suffix, so the last + // component is the shortest key), Vue through the same index, Python's + // `byBasename`, and C/C++'s basename map — for the last, only the header + // half is addressable, so only it repeats (see below). + layout === 'javascript' || + layout === 'typescript' || + layout === 'vue' || + layout === 'python' || + layout === 'c' || + layout === 'cpp'; + const [fileStem, modStem] = PASCAL_CASE_FILES.has(layout) ? ['File', 'Mod'] : ['file', 'mod']; + let stem = + shape === 'collide' && collideStem ? `${modStem}${Math.floor(i / dirs)}` : `${fileStem}${i}`; + // Rust's `mod.rs` and Python's `__init__.py`: one per directory, and the + // file every in-repo target of theirs resolves to. Minted at the first file + // of each directory (`i < dirs`, so `d === i`), which is why both arms' + // resolved counts are the count of in-repo imports either way. + if (PACKAGE_STEM[layout] !== undefined && i < dirs) stem = PACKAGE_STEM[layout]; + // C and C++ address only HEADERS, so only their stems repeat in the collide + // shape; the sources stay unique and are pure corpus weight, exactly as in a + // real tree where nobody `#include`s a `.c`. + if ((layout === 'c' || layout === 'cpp') && i % 2 === 0) stem = `src${i}`; + // Go's package leg must exclude `_test.go`; keep a real share of them. + // Kotlin resolves `.kt` and `.kts` through the same stem maps; keep both. + // COBOL's copybook tier (`.cpy`) BEATS its source tier (`.cbl`) on the same + // bookname, so both extensions have to be present for that tie-break to be + // reachable at all — and in the collide shape, where basenames repeat, one + // bookname really does land in both tiers. + // A Vue repo is `.vue` SFCs plus plain `.ts` modules, and only the second + // kind reaches the extension-guessing leg (SFC imports carry `.vue` + // explicitly), so both have to be present for both legs to be measured. + // C/C++ alternate header and source; the header half is the addressable one. + const suffix = + layout === 'go' && i % 6 === 0 + ? '_test.go' + : layout === 'kotlin' && i % 11 === 0 + ? '.kts' + : layout === 'cobol' && i % 3 === 0 + ? '.cpy' + : layout === 'vue' && i % 3 === 0 + ? '.ts' + : HEADER_EXTENSION[layout] !== undefined && i % 2 === 1 + ? HEADER_EXTENSION[layout] + : ext; + files.push(`${prefix}${dir}/${stem}${suffix}`); + } + // One real suffix decoy makes the PHP external gate observable: with the + // gate, Vendor0 stays unresolved; without it, suffix fallback resolves this + // path and the exact fingerprint/external-probe result changes. + if (layout === 'php' && files.length > 0) { + files[files.length - 1] = `${prefix}legacy/Vendor0/Ghost/Missing.php`; + } + return files; +} + +/** + * ONE `ParsedFile`, and the ONE place in this file that spells that shape. + * + * CARRIES THE FIELDS THE RESOLVERS READ AND NOTHING ELSE, deliberately. + * `filesByDirectory` reads `filePath`; PHP's declaring-file filter reads + * `localDefs[].type` and `localDefs[].qualifiedName`; Python's + * `pythonFileExportsName` reads `localDefs[].qualifiedName`. `scopes`, + * `parsedImports` and `referenceSites` are on the real shape and are inert on + * this path, and the timed corpora are rebuilt inside every pass (see + * `newPass`), so filling them would charge the RESOLUTION arms for extraction + * work that happens in another phase entirely. + * + * `nodeId` is inert as well — checked, not assumed: neither + * `php/import-target.ts` nor `python/import-target.ts` mentions it, and they are + * the two modules `resolveOne` enters. It is minted anyway because it is on the + * real shape, and its spelling is therefore free to be uniform. + * + * Both callers come through here — `buildParsedFiles` for the timed and heap + * corpora, `CONTEXT_PROBE` for the `context` arm's hand-built ones. It used to + * be spelled out twice, ~900 lines apart, differing only in that `nodeId`; this + * is an untyped `.mjs`, so nothing would have failed at build if `ParsedFile` + * grew a field and only one of the two copies learned about it. + */ +const probeFile = (filePath, defs) => ({ + filePath, + moduleScope: filePath, + scopes: [], + parsedImports: [], + localDefs: defs.map(([type, qualifiedName], n) => ({ + nodeId: `${filePath}#${n}`, + filePath, + type, + qualifiedName, + })), + referenceSites: [], +}); + +const javaProbeFile = (filePath, packageName) => ({ + ...probeFile(filePath, []), + captureSideChannel: { + kind: 'java', + packageFact: { status: 'known', packageName }, + classAnnotations: [], + }, +}); + +const kotlinProbeFile = (filePath, packageName, exportName) => { + const base = probeFile(filePath, [['Class', `${packageName}.${exportName}`]]); + const def = base.localDefs[0]; + const moduleScope = `module:${filePath}`; + return { + ...base, + moduleScope, + scopes: [ + { + id: moduleScope, + parent: null, + kind: 'Module', + range: { startLine: 1, startCol: 0, endLine: 1, endCol: 1 }, + filePath, + bindings: new Map([[exportName, [{ def, origin: 'local' }]]]), + ownedDefs: [def], + imports: [], + typeBindings: new Map(), + }, + ], + captureSideChannel: { + kind: 'kotlin', + companionScopes: [], + packageFact: { status: 'known', packageName }, + classAnnotations: [], + }, + }; +}; + +function javaBenchmarkPackage(filePath) { + const uniquePackage = /\/com\/example\/(pkg\d+)(?:\/|$)/.exec(`/${filePath}`)?.[1]; + if (uniquePackage !== undefined) return `com.example.${uniquePackage}`; + + return /\/svc\d+\/.*\/com\/example\/model(?:\/|$)/.test(`/${filePath}`) + ? 'com.example.model' + : ''; +} + +function kotlinBenchmarkPackage(filePath) { + const rootedPath = '/' + filePath; + const uniquePackage = /\/com\/example\/(pkg\d+)(?:\/|$)/.exec(rootedPath)?.[1]; + if (uniquePackage !== undefined) return `com.example.${uniquePackage}`; + return /\/com\/example\/models(?:\/|$)/.test(rootedPath) ? 'com.example.models' : ''; +} + +/** + * The `ParsedFile[]` the orchestrator threads beside the path set, for the three + * languages whose hook declares a `context` — see `CONTEXT_LANGS`. + * + * Two defs per file, and both are real shapes rather than padding. PHP keeps + * classes and functions in SEPARATE symbol tables, so `App\Ns7\File7` naming + * both a class and a function is ordinary PHP — and it is what makes the leg's + * two halves reachable on the same corpus: the class def exercises the + * `def.type !== expectedType` reject (which returns before the split) and the + * function def exercises the `split(/[\\.]/).at(-1)` compare that decides the + * match. The qualified name carries two separators because that split's cost is + * a function of how many there are, and a one-segment name would understate it. + * + * The owner segment is the file's own directory name (`Ns7`, `Models`, `pkg7`), + * which is stable across the `small`, `deep` and `collide` arms — so the `deep` + * arm differs from `small` in path DEPTH alone, exactly as it does for the path + * set. `filesByDirectory` is exact and linear in the file count; the shared + * suffix index remains the path-depth-sensitive structure this arm measures. + */ +function buildParsedFiles(lang, files) { + const parsedFiles = []; + for (const filePath of files) { + if (lang === 'java') { + parsedFiles.push(javaProbeFile(filePath, javaBenchmarkPackage(filePath))); + continue; + } + if (lang === 'kotlin') { + const slash = filePath.lastIndexOf('/'); + const stem = filePath.slice(slash + 1, filePath.lastIndexOf('.')); + parsedFiles.push(kotlinProbeFile(filePath, kotlinBenchmarkPackage(filePath), stem)); + continue; + } + const slash = filePath.lastIndexOf('/'); + const stem = filePath.slice(slash + 1, filePath.lastIndexOf('.')); + const parent = slash < 0 ? '' : filePath.slice(0, slash); + const owner = parent.slice(parent.lastIndexOf('/') + 1); + const qualifiedName = lang === 'php' ? `App\\${owner}\\${stem}` : `${owner}.${stem}`; + parsedFiles.push( + probeFile(filePath, [ + ['Class', qualifiedName], + ['Function', qualifiedName], + ]), + ); + } + return parsedFiles; +} + +/** + * The import one file issues in the UNIQUE-LEAF layout `uniqueDir` produced. + * + * The TARGET axis is split from the DIRECTORY axis exactly the way `uniqueDir` + * and `collideDir` split it above — two flat functions, selected once — rather + * than a `collide ?` ternary threaded through seventeen languages' `local ? …` + * ladders. `local` picks in-repo vs external; the handful of MISS lines that + * are identical between the two shapes are duplicated on purpose, because the + * alternative is four levels of nesting in a single expression. + */ +function uniqueTarget(lang, { local, r, d, j, dirs }) { + if (lang === 'go') { + return local + ? `${GO_MODULE.modulePath}/src/pkg${d}` + : (r >>> 3) % 2 === 0 + ? ['fmt', 'os', 'net/http', 'encoding/json'][(r >>> 4) % 4] + : `github.com/org/repo${(r >>> 4) % 97}/pkg/util`; + } + if (lang === 'csharp') { + return local + ? `App.Ns${d}` + : (r >>> 3) % 2 === 0 + ? ['System', 'System.Threading.Tasks', 'System.Collections.Generic'][(r >>> 4) % 3] + : `Ghost${(r >>> 4) % 97}.Deep.Missing`; + } + if (lang === 'csharp_csproj') { + // The mix is the arm. `System` and `Ghost{n}.Deep.Missing` match NEITHER + // root namespace, so they `continue` straight out of the config loop + // (csharp.ts:231-241) and never reach the indexed leg at all — an arm built + // on the no-csproj arm's spelling mix would measure #2902 not at all. They + // are kept as the fast-`continue` control at 1 slot in 8; the other four + // external slots address a root namespace on purpose. + if (local) return `App.Ns${d}`; + const leg = (r >>> 3) % 5; + // Matches `App`, misses every directory: `dirPrefix = 'src/Missing{n}'`, + // whose last segment buckets to nothing. 2 slots in 8. + if (leg < 2) return `App.Missing${(r >>> 4) % 97}`; + // Matches `Lib`, whose `projectDir` is empty, so `dirPrefix` is slash-FREE + // and `candidateDirs` sweeps the last-segment keys — the one leg of the + // three whose cost is not constant in the corpus. See `_arms_note`. + if (leg === 2) return `Lib.Missing${(r >>> 4) % 97}`; + // The import IS a root namespace with no `projectDir`: `dirPrefix` is + // EMPTY, the query no last-segment bucket expresses, answered from + // `singleSegmentDirs`. + if (leg === 3) return 'Lib'; + return (r >>> 4) % 2 === 0 + ? ['System', 'System.Threading.Tasks', 'System.Collections.Generic'][(r >>> 5) % 3] + : `Ghost${(r >>> 4) % 97}.Deep.Missing`; + } + if (lang === 'dart') { + return local + ? `package:app/feature${d}/file${j}.dart` + : (r >>> 3) % 3 === 0 + ? ['dart:core', 'dart:async', 'dart:io'][(r >>> 4) % 3] + : `package:ext${(r >>> 4) % 97}/src/thing.dart`; + } + if (lang === 'kotlin') { + // A share of wildcard imports: `.*` lands on the package fan-out tier, + // which returns a LIST and is the only tier whose output is order-bearing. + return local + ? (r >>> 3) % 3 === 0 + ? `com.example.pkg${d}.*` + : `com.example.pkg${d}.File${j}` + : (r >>> 3) % 2 === 0 + ? ['java.util.List', 'kotlin.collections.Map', 'kotlinx.coroutines.flow.Flow'][ + (r >>> 4) % 3 + ] + : `com.ghost${(r >>> 4) % 97}.deep.Missing`; + } + if (lang === 'php') { + if (local) { + const namespace = d % 7 === 0 ? `Ns${d}\\Sub\\Ns${d}` : `Ns${d}`; + const leadingSeparator = (r >>> 3) % 4 === 0 ? '\\' : ''; + return `${leadingSeparator}App\\${namespace}\\File${j}`; + } + return (r >>> 3) % 2 === 0 + ? [ + 'Psr\\Log\\LoggerInterface', + 'Symfony\\Component\\Console\\Command', + 'Doctrine\\ORM\\EntityManager', + ][(r >>> 4) % 3] + : `Vendor${(r >>> 4) % 97}\\Ghost\\Missing`; + } + if (lang === 'java') { + // Java has NO in-repo-namespace gate (#2910 is filed for it), so a JDK + // import genuinely can resolve to a local file — `java.util.List` would + // answer to a `util/List.java` anywhere in the repo, and the progressive + // stripping loop would find it by its bare basename. These spellings are + // chosen to miss on THIS corpus (whose files are all `File{i}.java` under + // `…/pkg{d}/`) and the resolved count is asserted, not assumed. + return local + ? (r >>> 3) % 3 === 0 + ? `com.example.pkg${d}.*` + : `com.example.pkg${d}.File${j}` + : (r >>> 3) % 2 === 0 + ? ['java.util.List', 'java.io.IOException', 'java.util.concurrent.ConcurrentHashMap'][ + (r >>> 4) % 3 + ] + : `com.google.common.vendor${(r >>> 4) % 97}.Missing`; + } + if (lang === 'cobol') { + // `COPY` takes a bare bookname. A share of the local ones is spelled in + // lower case: COBOL is case-insensitive and the resolver upper-cases the + // target, so those must resolve to the same file — free coverage of the + // one transformation on the lookup path. + return local + ? (r >>> 3) % 3 === 0 + ? `file${j}` + : `File${j}` + : (r >>> 3) % 2 === 0 + ? ['DFHAID', 'DFHBMSCA', 'SQLCA', 'CICSDEF'][(r >>> 4) % 4] + : `VENDOR${(r >>> 4) % 97}`; + } + if (lang === 'swift') { + // `import X` names an SPM MODULE, never a file, so there is no `.File{j}` + // spelling to mint: the target is the module and the answer is its whole + // file list. The misses are the frameworks that ship with the platform and + // the SPM packages that live in `.build/`, i.e. outside the corpus. + return local + ? `Mod${d}` + : (r >>> 3) % 2 === 0 + ? ['Foundation', 'UIKit', 'Combine', 'SwiftUI'][(r >>> 4) % 4] + : `ExternalPkg${(r >>> 4) % 97}`; + } + if (lang === 'rust') { + // `crate::mod{d}::thing` resolves by PROBING: `src/mod{d}/thing.rs`, + // `src/mod{d}/thing/mod.rs`, `src/mod{d}.rs`, then `src/mod{d}/mod.rs`, + // which hits. The `d % 7` slice has no `mod.rs` at that path and misses, + // which is where the resolved count comes from. + return local + ? `crate::mod${d}::thing` + : (r >>> 3) % 2 === 0 + ? ['std::collections::HashMap', 'tokio::sync::mpsc', 'serde::Deserialize'][(r >>> 4) % 3] + : `ghost${(r >>> 4) % 97}::Missing`; + } + if (lang === 'python') { + // Dotted absolute imports. The stdlib spellings and the unknown + // distributions both die at `hasRepoCandidate`, which is the gate that + // keeps `django.apps` off a local `accounts/apps.py`. + return local + ? `pkg${d}.file${j}` + : (r >>> 3) % 2 === 0 + ? ['os.path', 'collections.abc', 'django.db.models'][(r >>> 4) % 3] + : `vendor${(r >>> 4) % 97}.deep.missing`; + } + if (lang === 'javascript' || lang === 'typescript') { + // BARE specifiers, not relative ones. A relative import resolves by exact + // `Set.has` and never reaches `suffixResolve` — the leg that had no index + // for JavaScript until PR #2911 and cost 25 972 µs per import at 8000 + // files — so a corpus of `./sibling` imports would measure the wrong one. + return local + ? `src/mod${d}/file${j}` + : (r >>> 3) % 2 === 0 + ? ['react', 'lodash/fp', '@scope/ui/dist/index'][(r >>> 4) % 3] + : `vendor${(r >>> 4) % 97}/lib/missing`; + } + if (lang === 'vue') { + // Every in-repo import is `@/…`, so the alias branch runs on all of them. + // The `.vue` share carries its extension (SFC imports always do) and takes + // the exact-path leg; the `.ts` share omits it and takes the guessing leg. + return local + ? j % 3 === 0 + ? `@/mod${d}/File${j}` + : `@/mod${d}/File${j}.vue` + : (r >>> 3) % 2 === 0 + ? ['vue', 'pinia', '@vueuse/core'][(r >>> 4) % 3] + : `vendor${(r >>> 4) % 97}/lib/Missing.vue`; + } + if (lang === 'c' || lang === 'cpp') { + // `#include "comp{d}/file{j}.h"`. `j | 1` picks the HEADER half of the + // corpus — the even half is `.c`/`.cpp` and nothing includes those. The + // misses are the two kinds a real tree has: a system header that is not in + // the repo at all, and a vendored path that does not exist. + const h = HEADER_EXTENSION[lang]; + const jj = j | 1; + return local + ? `comp${jj % dirs}/file${jj}${h}` + : (r >>> 3) % 2 === 0 + ? ['stdio.h', 'stdlib.h', 'string.h'][(r >>> 4) % 3] + : `vendor${(r >>> 4) % 97}/missing${h}`; + } + if (lang === 'ruby') { + return local + ? `mod${d}/file${j}` + : (r >>> 3) % 2 === 0 + ? ['json', 'set', 'net/http', 'digest'][(r >>> 4) % 4] + : `gem${(r >>> 4) % 97}/missing/thing`; + } + throw unwiredLanguage('uniqueTarget', lang); +} + +/** + * The same import in the SHARED-LEAF layout `collideDir` produced. Each + * language's local spelling is chosen so this arm resolves exactly as many + * imports as the unique arm does (asserted): same workload, different layout. + */ +function collideTarget(lang, { local, r, d, j, dirs }) { + if (lang === 'go') { + return local + ? // The replicated package is addressed by the path it shares, so the + // module leg matches every service at once (`dirCount > 1`). + d % 5 === 1 && d % 7 !== 0 + ? `${GO_MODULE.modulePath}/internal/shared` + : `${GO_MODULE.modulePath}/svc${d}/internal` + : (r >>> 3) % 2 === 0 + ? ['fmt', 'os', 'net/http', 'encoding/json'][(r >>> 4) % 4] + : // Ends in the shared segment, so the GOPATH fallback walks the whole + // bucket three times and still returns null: the MISS path this arm + // exists to measure. + `github.com/org/repo${(r >>> 4) % 97}/internal`; + } + if (lang === 'csharp') { + return local + ? // This used to send the `d % 7` slice to `App.Src{d}.Vendor`, a + // namespace with no directory anywhere, to mirror the unique arm's + // nested-same-name slice, which also resolved to nothing. #2881 made + // that slice resolve, so the mirror has to as well — otherwise this arm + // stops resolving as many imports as `small`, which is the invariant + // that makes the two timings comparable and is asserted below. + `App.Src${d}.Models` + : (r >>> 3) % 2 === 0 + ? ['System', 'System.Threading.Tasks', 'System.Collections.Generic'][(r >>> 4) % 3] + : `Ghost${(r >>> 4) % 97}.Deep.Missing`; + } + if (lang === 'csharp_csproj') { + // Same five families as the unique arm, in the same proportions, so the + // resolved count is identical by construction (asserted). Two things change. + // + // The local spelling moves onto the SECOND config: the collide layout puts + // nothing under `src/`, so `projectDir: 'src'` addresses no directory here + // and `App.Src{d}.Models` would resolve nothing. `Lib` (`projectDir: ''`) + // addresses `Src{d}/Models` directly — the same relayout-not-reworkload + // substitution every other language makes in this function. + // + // And `dirsByLastSegment` collapses from one key per directory to the + // single key `Models`, which makes the slash-free SWEEP cheaper here than + // on the unique layout while making the bucket the nested slice walks hold + // every directory — the inverse of the go/csharp/dart collide arms, whose + // every term gets worse. See `_arms_note`. + if (local) return `Lib.Src${d}.Models`; + const leg = (r >>> 3) % 5; + if (leg < 2) return `App.Missing${(r >>> 4) % 97}`; + if (leg === 2) return `Lib.Missing${(r >>> 4) % 97}`; + if (leg === 3) return 'Lib'; + return (r >>> 4) % 2 === 0 + ? ['System', 'System.Threading.Tasks', 'System.Collections.Generic'][(r >>> 5) % 3] + : `Ghost${(r >>> 4) % 97}.Deep.Missing`; + } + if (lang === 'dart') { + return local + ? `package:app/pkg${j % dirs}/lib/src/mod${Math.floor(j / dirs)}.dart` + : (r >>> 3) % 3 === 0 + ? ['dart:core', 'dart:async', 'dart:io'][(r >>> 4) % 3] + : // A repeated basename under a directory nothing carries: both + // candidates walk the whole basename bucket and miss. + `package:ext${(r >>> 4) % 97}/other/mod${(r >>> 4) % 8}.dart`; + } + if (lang === 'kotlin') { + // Same wildcard share and declared-package workload as the unique arm. + // Directory collisions must not affect semantic package resolution. + return local + ? (r >>> 3) % 3 === 0 + ? `com.example.models.*` + : `com.example.models.File${j}` + : (r >>> 3) % 2 === 0 + ? ['java.util.List', 'kotlin.collections.Map', 'kotlinx.coroutines.flow.Flow'][ + (r >>> 4) % 3 + ] + : `com.ghost${(r >>> 4) % 97}.deep.Missing`; + } + if (lang === 'php') { + if (local) { + const leadingSeparator = (r >>> 3) % 4 === 0 ? '\\' : ''; + return `${leadingSeparator}App\\Svc${j % dirs}\\Models\\Mod${Math.floor(j / dirs)}`; + } + return (r >>> 3) % 2 === 0 + ? [ + 'Psr\\Log\\LoggerInterface', + 'Symfony\\Component\\Console\\Command', + 'Doctrine\\ORM\\EntityManager', + ][(r >>> 4) % 3] + : `Vendor${(r >>> 4) % 97}\\Ghost\\Missing`; + } + if (lang === 'java') { + // Every file declares the same package despite living under different + // service paths. Exact and wildcard imports therefore exercise one growing + // declared-package bucket without relying on directory layout. + return local + ? (r >>> 3) % 3 === 0 + ? 'com.example.model.*' + : `com.example.model.File${j}` + : (r >>> 3) % 2 === 0 + ? ['java.util.List', 'java.io.IOException', 'java.util.concurrent.ConcurrentHashMap'][ + (r >>> 4) % 3 + ] + : `com.google.common.vendor${(r >>> 4) % 97}.Missing`; + } + if (lang === 'cobol') { + // The repeated basename is COBOL's ONLY collision axis, and its index is a + // keyed map, so this arm asserts immunity. It also reaches the tier + // tie-break the unique arm cannot: `Mod{n}` now names both a `.cpy` and a + // `.cbl`, and the copybook must win regardless of Set-iteration order. + return local + ? (r >>> 3) % 3 === 0 + ? `mod${Math.floor(j / dirs)}` + : `Mod${Math.floor(j / dirs)}` + : (r >>> 3) % 2 === 0 + ? ['DFHAID', 'DFHBMSCA', 'SQLCA', 'CICSDEF'][(r >>> 4) % 4] + : `VENDOR${(r >>> 4) % 97}`; + } + if (lang === 'swift') { + // Four modules instead of `dirs` of them, so the bucket a hit returns holds + // fileCount/4 files and grows with the corpus. Same in-repo share, same + // resolved count; the only thing that changed is bucket cardinality. + return local + ? `Mod${d % SWIFT_COLLIDE_MODULES}` + : (r >>> 3) % 2 === 0 + ? ['Foundation', 'UIKit', 'Combine', 'SwiftUI'][(r >>> 4) % 4] + : `ExternalPkg${(r >>> 4) % 97}`; + } + if (lang === 'rust') { + // ~2x the `::` segments of the unique arm, in both the hits and the misses, + // because SEGMENT COUNT is the only axis this resolver's cost has. The + // `d % 7` slice names a module that exists nowhere, mirroring the unique + // arm's `inner` slice, so the resolved count is unchanged. The external + // spellings run the prefix-shortening loop in `resolveModulePath` to the + // end — two `.has()` probes per shortened prefix — which is the longest + // path through the function and the one worth an absolute ceiling. + return local + ? d % 7 === 0 + ? `crate::l0::l1::l2::l3::l4::vendor${d}::thing::Inner` + : `crate::l0::l1::l2::l3::l4::mod${d}::thing::Inner` + : (r >>> 3) % 2 === 0 + ? [ + 'std::collections::hash_map::HashMap', + 'tokio::sync::mpsc::channel', + 'serde::de::value::MapDeserializer', + ][(r >>> 4) % 3] + : `ghost${(r >>> 4) % 97}::deep::nested::more::Missing`; + } + if (lang === 'python') { + // A `models` package in every service and a repeated `mod{n}.py` inside it, + // so `byBasename` holds one entry per service for each stem and the + // fewest-segments-then-lexicographic tie-break in `resolveAbsoluteFromFiles` + // actually has something to break. The external spelling shares the + // basename and still misses — `vendor{n}` fails `hasRepoCandidate`. + return local + ? `svc${j % dirs}.models.mod${Math.floor(j / dirs)}` + : (r >>> 3) % 2 === 0 + ? ['os.path', 'collections.abc', 'django.db.models'][(r >>> 4) % 3] + : `vendor${(r >>> 4) % 97}.models.mod0`; + } + if (lang === 'javascript' || lang === 'typescript') { + // `pkg{n}/src/mod{m}` in every package. `buildSuffixIndex` is a KEYED map + // that keeps one path per suffix, so this is the arm that asserts the + // ts-family resolver is collision-immune. The external spelling must not + // share the repeated stem, or it would suffix-match a real file and the + // corpus would stop being miss-heavy (measured: 67% resolved instead of + // 36% when it was `vendor{n}/src/mod{m}`). + return local + ? `pkg${j % dirs}/src/mod${Math.floor(j / dirs)}` + : (r >>> 3) % 2 === 0 + ? ['react', 'lodash/fp', '@scope/ui/dist/index'][(r >>> 4) % 3] + : `vendor${(r >>> 4) % 97}/src/ghost${(r >>> 4) % 8}`; + } + if (lang === 'vue') { + return local + ? j % 3 === 0 + ? `@/pkg${j % dirs}/components/Mod${Math.floor(j / dirs)}` + : `@/pkg${j % dirs}/components/Mod${Math.floor(j / dirs)}.vue` + : (r >>> 3) % 2 === 0 + ? ['vue', 'pinia', '@vueuse/core'][(r >>> 4) % 3] + : `vendor${(r >>> 4) % 97}/components/Ghost${(r >>> 4) % 8}.vue`; + } + if (lang === 'c' || lang === 'cpp') { + // A `mod{n}` header in every service's `include/`, which is what a C tree + // looks like. The basename bucket the suffix fallback walks now holds one + // candidate per service, so the depth-then-lexicographic tie-break decides + // — and the bucket grows with the corpus, which is why this arm carries its + // own scaling budget. + const h = HEADER_EXTENSION[lang]; + const jj = j | 1; + return local + ? `include/mod${Math.floor(jj / dirs)}${h}` + : (r >>> 3) % 2 === 0 + ? ['stdio.h', 'stdlib.h', 'string.h'][(r >>> 4) % 3] + : `vendor${(r >>> 4) % 97}/mod0${h}`; + } + if (lang === 'ruby') { + // `models/mod{n}.rb` in every package. Ruby answers `require` from a keyed + // suffix map, so the repeated basename cannot grow a bucket: this arm + // asserts that immunity, which is why its collide budget is the linear one. + return local + ? `svc${j % dirs}/lib/models/mod${Math.floor(j / dirs)}` + : (r >>> 3) % 2 === 0 + ? ['json', 'set', 'net/http', 'digest'][(r >>> 4) % 4] + : `gem${(r >>> 4) % 97}/missing/thing`; + } + throw unwiredLanguage('collideTarget', lang); +} + +/** + * One synthetic repository per language: the file set plus the import list each + * file issues. + */ +function buildRepo(lang, fileCount, pad = 0, shape = 'unique') { + const dirs = dirsFor(fileCount); + const files = buildFiles(lang, fileCount, pad, shape); + const mintTarget = shape === 'collide' ? collideTarget : uniqueTarget; + + const imports = []; + for (let i = 0; i < fileCount; i++) { + const from = files[i]; + for (let k = 0; k < IMPORTS_PER_FILE; k++) { + const r = mix(i * 65599 + k); + // ~3 in 8 imports resolve in-repo; the rest are external and run the + // whole cascade to completion (corpus property 1). + const local = r % 8 < 3; + const d = r % dirs; + const j = r % fileCount; + imports.push([from, mintTarget(lang, { local, r, d, j, dirs })]); + } + } + if (lang === 'php' && imports.length > 0) { + imports[0] = [files[0], 'Vendor0\\Ghost\\Missing']; + } + return { files, imports }; +} + +/** + * The per-pass state one resolver sees: the file set it is handed, and the + * `resolutionConfig` the orchestrator threads beside it. + * + * `allFilePaths` is a FRESH Set per pass on purpose — every per-file-set memo + * in `import-resolvers/per-file-set.ts` is keyed on that object's identity, so + * reusing one Set across passes would hide the index build after the first and + * let a rebuilt-per-import index look free from rep 2 onward. + * + * `config` is why this exists as a function rather than a `new Set(files)` at + * three call sites. Three languages here need one and they need three different + * things: + * + * - C and C++ take their HEADERS through `resolutionConfig`, not through + * `allFilePaths`. The phase hands the C resolver the `.c` files it + * classified and the header scan separately, and + * `augmentedFilePathsFor(allFilePaths)(headerPaths)` unions the two ONCE per + * pass — a two-input memo, so both inputs have to be pass-stable or it + * rebuilds an O(files) Set per include. Splitting the corpus here is what + * makes that union reachable at all; handing the resolver one pre-merged set + * would leave the memo, and the shape it exists for, unmeasured. + * - Vue takes `tsconfigPaths`, and the alias branch is the one leg of the + * shared ts-family resolver its arm covers that the other two do not. + * + * `csharp_csproj` is the precedent and stays where it is: a per-language + * CONTEXT over a corpus aliased to another language's, rather than a new axis. + * + * `parsedFiles` is the third pass-stable object, present for `CONTEXT_LANGS` + * and undefined for everyone else. It is built BEFORE the path set and the path + * set is derived FROM it, which is not a stylistic choice: `run.ts` does + * `new Set(parsedFiles.map((f) => f.filePath))`, so two independently built + * lists would be a shape the pipeline cannot produce. Fresh per pass for + * exactly the reason the Set is — `filesByDirectory` and `parsedFileByPath` are + * `perFileSet` memos keyed on this ARRAY's identity, so reusing one array would + * hide their build from rep 2 onward and `fastest()` reports the minimum. + */ +function newPass(lang, files, pad = 0) { + if (HEADER_EXTENSION[lang] !== undefined) { + const sources = []; + const headers = []; + for (const f of files) (f.endsWith(HEADER_EXTENSION[lang]) ? headers : sources).push(f); + return { allFilePaths: new Set(sources), config: new Set(headers) }; + } + if (lang === 'vue') { + return { allFilePaths: new Set(files), config: vueTsconfig(tsBaseUrlFor(pad)) }; + } + // javascript and typescript resolve their `src/mod{d}/file{j}` locals through + // `baseUrl`; without a config every arm would correctly resolve nothing and + // measure an empty branch (#2953 — see `tsBaseUrlConfig`). + if (lang === 'javascript' || lang === 'typescript') { + return { allFilePaths: new Set(files), config: tsBaseUrlConfig(tsBaseUrlFor(pad)) }; + } + if (CONTEXT_LANGS.includes(lang)) { + const parsedFiles = buildParsedFiles(lang, files); + restoreBenchmarkSideChannels(lang, parsedFiles); + return { + allFilePaths: new Set(parsedFiles.map((f) => f.filePath)), + config: lang === 'php' ? phpComposerConfigFor(pad) : undefined, + parsedFiles, + }; + } + return { allFilePaths: new Set(files), config: undefined }; +} + +/** + * The `{ parsedFiles, parsedImport }` object `run.ts` mints per import — per + * import there too, so this allocation is production's, not the bench's. + * + * `undefined` when the pass carries no parsed workspace, which happens in + * exactly one place: the CONTROL half of the `context` arm, whose whole job is + * to prove the arm can tell the two call shapes apart. + */ +const contextFor = (pass, parsedImport) => + pass.parsedFiles === undefined + ? undefined + : { parsedFiles: pass.parsedFiles, parsedImport, filesSkipped: 0 }; + +function restoreBenchmarkSideChannels(lang, parsedFiles) { + const resolver = + lang === 'java' ? javaScopeResolver : lang === 'kotlin' ? kotlinScopeResolver : undefined; + if (resolver === undefined) return; + resolver.loadResolutionConfig?.(''); + for (const parsed of parsedFiles) resolver.applyCaptureSideChannel?.(parsed); +} + +/** The timed loop. One `newPass` per pass, so every pass pays exactly one index + * build — see `newPass`. */ +function resolveAll(lang, files, imports, pad = 0) { + const pass = newPass(lang, files, pad); + let sink = 0; + for (const [from, target] of imports) { + const hit = resolveOne(lang, from, target, pass); + if (hit !== null) sink++; + } + return sink; +} + +function resolveOne(lang, from, target, pass) { + const allFilePaths = pass.allFilePaths; + if (lang === 'go') return resolveGoImportTarget(target, from, allFilePaths, GO_MODULE); + if (lang === 'dart') return resolveDartImportTarget(target, from, allFilePaths); + if (lang === 'ruby') return resolveRubyImportTarget(target, from, allFilePaths); + if (lang === 'kotlin') { + const parsedImport = { + kind: 'named', + localName: 'X', + importedName: 'X', + targetRaw: target, + }; + return kotlinScopeResolver.resolveImportTarget( + target, + from, + allFilePaths, + pass.config, + contextFor(pass, parsedImport), + ); + } + // `pass.config` is undefined for PHP, so no composer.json: the PSR-4 mapping + // legs are skipped and every import lands on the suffix cascade #2901 + // indexed. The FIFTH argument is the production one, and + // `importedSymbolKind: 'function'` is what opens the named/alias leg over + // `filesByDirectory(context.parsedFiles)` — see THE FIFTH ARGUMENT. It runs + // on every import rather than on a share of them because the leg is the point + // of the arm and it costs the cascade nothing: `resolvePhpImportInternal` + // has already returned by the time the leg is consulted, so this arm still + // measures everything it measured before, plus the leg. + // + // `importedName` is inert here and stays 'X' like the java and kotlin arms: + // the leg derives the name it matches on from `targetRaw` itself, so + // computing a real one would be a split per import charged to the timed loop + // for a field nothing reads. + if (lang === 'php') { + return resolvePhpImportTargetInternal( + target, + from, + allFilePaths, + pass.config, + contextFor(pass, { + kind: 'named', + localName: 'X', + importedName: 'X', + targetRaw: target, + importedSymbolKind: 'function', + }), + ); + } + if (lang === 'java') { + const parsedImport = { + kind: 'named', + localName: 'X', + importedName: 'X', + targetRaw: target, + }; + return javaScopeResolver.resolveImportTarget( + target, + from, + allFilePaths, + pass.config, + contextFor(pass, parsedImport), + ); + } + // The `ScopeResolver` hook itself — COBOL's copy index has no other export. + if (lang === 'cobol') return cobolScopeResolver.resolveImportTarget(target, from, allFilePaths); + if (lang === 'swift') { + return resolveSwiftImportTarget( + { kind: 'namespace', localName: 'X', importedName: 'X', targetRaw: target }, + { fromFile: from, allFilePaths }, + ); + } + if (lang === 'rust') return resolveRustImportTarget(target, from, allFilePaths, undefined); + if (lang === 'python') { + // `from import X` — the spelling the orchestrator actually hands + // the provider, and the ONLY one that reads `context.parsedFiles`: a + // `namespace` import makes `pythonImportedSubmoduleTarget` return null, the + // submodule-precedence branch never runs and the field is dead. This arm + // used to pass that synthetic namespace spelling and skipped the branch + // for exactly that reason, which is what made the field unmeasurable. + // + // So the arm now pays the branch: a package probe, a `parsedFileByPath` + // lookup over the resolved package's `localDefs`, and a submodule probe — + // up to three entries into the resolver per import, which is why its ms + // numbers are several times what the namespace spelling read. That IS the + // per-import cost of a `from … import …` in production. + // + // 'X' names nothing the corpus declares, on purpose: `pythonFileExportsName` + // then scans the whole `localDefs` list and returns false, so every + // resolving import runs the submodule probe too. That is the expensive + // half of the branch — a name the package DOES export short-circuits at + // the first def — and matches corpus property 1 above. + // + // The `{ fromFile, allFilePaths, parsedFiles }` shape is exactly what + // `pythonScopeResolver` builds from the context before calling this. + return resolvePythonImportTarget( + { kind: 'named', localName: 'X', importedName: 'X', targetRaw: target }, + { fromFile: from, allFilePaths, parsedFiles: pass.parsedFiles }, + ); + } + if (lang === 'javascript') { + return jsResolveImportTarget(target, from, allFilePaths, pass.config); + } + if (lang === 'vue') return vueResolveImportTarget(target, from, allFilePaths, pass.config); + // TypeScript, C and C++ go through the registered `ScopeResolver` hook rather + // than an inner resolver, because for all three the thing under test lives IN + // the adapter: TypeScript's `tsPassCacheFor` memo is private to + // `typescript/scope-resolver.ts`, and C's and C++'s `augmentedFilePathsFor` + // is private to theirs. Calling past it would benchmark a copy of the adapter + // instead of the adapter. + if (lang === 'typescript') { + return typescriptScopeResolver.resolveImportTarget(target, from, allFilePaths, pass.config); + } + if (lang === 'c') { + return cScopeResolver.resolveImportTarget(target, from, allFilePaths, pass.config); + } + if (lang === 'cpp') { + return cppScopeResolver.resolveImportTarget(target, from, allFilePaths, pass.config); + } + if (lang === 'csharp' || lang === 'csharp_csproj') { + return resolveCsharpImportTarget( + { kind: 'namespace', localName: '_', importedName: '_', targetRaw: target }, + { + fromFile: from, + allFilePaths, + // The ONLY difference between the two C# arms. Present, the adapter + // takes the csproj branch and never falls through to the no-csproj legs. + ...(lang === 'csharp_csproj' ? { csharpConfigs: CSPROJ_CONFIGS } : {}), + }, + ); + } + throw unwiredLanguage('resolveOne', lang); +} + +/** The single untimed identity pass, producing BOTH non-timing results: the + * distinct `from|target → result` set the fingerprint hashes, and `resolved` + * counted over every import. One resolve per DISTINCT pair — on a fixed file + * set the resolvers are pure, so a repeated pair can only re-derive what the + * first occurrence already recorded, and the memoized `key → wasNull` answers + * the count for the repeat. Merged from two passes that each walked the whole + * corpus; the duplicate resolves measured ~1.15 s of an 11.4 s run. + * + * Deliberately NOT shared with `resolveAll`, which is the TIMED loop: the memo + * that makes this pass cheap is exactly what would hide the cost that loop + * exists to measure. */ +function identityPass(lang, files, imports, pad = 0) { + const pass = newPass(lang, files, pad); + const outcomes = new Set(); + const wasNullByKey = new Map(); + let resolved = 0; + for (const [from, target] of imports) { + const key = `${from}\u0000${target}`; + let wasNull = wasNullByKey.get(key); + if (wasNull !== undefined) { + if (!wasNull) resolved++; + continue; + } + const hit = resolveOne(lang, from, target, pass); + wasNull = hit === null; + wasNullByKey.set(key, wasNull); + if (!wasNull) resolved++; + const rendered = renderResolved(hit); + outcomes.add(`${key}\u0000${rendered}`); + } + return { outcomes, resolved }; +} + +/** One resolver answer as a comparable string. Kotlin and Java can return a + * LIST (their wildcard tier), so the array form is part of the shape, and + * `` keeps a miss distinct from a resolver that answered the empty + * string. Shared by the fingerprint above and by the `context` arm below, so + * the two never drift into reporting one answer two ways. */ +function renderResolved(hit) { + if (hit === null) return ''; + return Array.isArray(hit) ? hit.join(',') : hit; +} + +/** MIN, not median: both scales are timed in one process and every error source + * (GC, scheduler preemption, a noisy CI neighbour) is additive, so the fastest + * observed pass is the closest estimate of the uncontended cost. */ +function fastest(values) { + return Math.min(...values); +} + +function timeResolution(lang, files, imports, reps, pad = 0) { + for (let w = 0; w < WARMUP; w++) resolveAll(lang, files, imports, pad); + const samples = []; + for (let r = 0; r < reps; r++) { + const t0 = performance.now(); + resolveAll(lang, files, imports, pad); + samples.push(performance.now() - t0); + } + return fastest(samples); +} + +/** + * One WARMED pass, used only to size `reps` for the language. + * + * Run on the `small` arm, and `small` is measurably the cheapest of the five + * for every language where the answer can differ — all six that come out below + * `REPS_MAX` (csharp_csproj, ruby, php, javascript, typescript, vue). Three + * languages do have a cheaper arm — cobol's `collide` by 45%, kotlin's by 7%, + * dart's `deep` by a few percent — and all three sit so far under + * `REPS_CHEAP_MS` that either reading returns 15. `small` is also the arm + * `small_ms_ceiling` bounds, so it is the one number here a reader already has + * an intuition for. + * + * Warmed rather than taken from the WARMUP passes themselves: an unwarmed pass + * reads several times high, which would push the expensive languages to + * `REPS_MIN` for the wrong reason. + */ +function probeMs(lang, files, imports, pad = 0) { + for (let w = 0; w < WARMUP; w++) resolveAll(lang, files, imports, pad); + const t0 = performance.now(); + resolveAll(lang, files, imports, pad); + return performance.now() - t0; +} + +/** + * Retained JS heap of everything one language derives from one file set — + * measured by RESOLVING AN IMPORT through it, never by calling a builder. + * + * THE ARM READS WHAT THE LANGUAGE READS, and that is now the whole design. + * Until #2903 was extended to the two suffix maps, four of these arms called + * `getWorkspaceFileIndex(set)` directly and read `index.all.length`, which asks + * no suffix question at all. That was harmless only while `buildSuffixIndex` + * built both maps eagerly. The moment they went lazy the direct call built NO + * map, all four arms reported 0 B at 32 000 files, and 0 B is under every + * ceiling — four gates silently became ceilings over nothing, which is exactly + * the failure this file's header warns about for rust and cobol. Driving the + * real resolver cannot fail that way: whatever maps the language forces are the + * maps it forces in production, and if a resolver starts asking a new question + * the number moves on its own instead of needing this file edited. + * + * It also happens to be the only form available for half of these languages — + * Swift's `getSwiftModuleIndex`, Python's `getPythonFileIndex`, C's + * `suffixIndex` and the ts-family `passCacheFor` are private to their modules, + * and exporting four builders to feed a bench would widen four module surfaces + * for a measurement's convenience. Now that all eight arms use one form, the + * readings ARE comparable to one another (they were not before). + * + * GROWTH form, not the release form `bench/cfg/measure.mjs` uses: every index + * here is memoized in a `WeakMap` keyed on the Set, so releasing it means + * releasing the Set too, which would fold the Set's own cost into the delta. + * Here the pass is live across BOTH samples and the `files` array holds the + * path strings, so the delta is the derived structures' own footprint and not + * the paths they point at. For C and C++ it legitimately includes the augmented + * Set, which is part of what they hold; for every language it includes the one + * or two resolve-cache entries the probe leaves behind. + */ +function retainedPassBytes(lang, files, probeTarget, pad = 0) { + const pass = newPass(lang, files, pad); + // See `HEAP_RETAINED`: nothing built for this language is released until the + // next one starts, so no deferred collection can land between the two samples + // below and cancel part of the delta. + HEAP_RETAINED.push(pass); + GC(); + const before = process.memoryUsage().heapUsed; + const hit = resolveOne(lang, files[0], probeTarget, pass); + GC(); + const after = process.memoryUsage().heapUsed; + // A HIT would mean the reading is a materialized answer rather than the + // index, and — for the languages whose cascade returns early — that the legs + // past the hit were never reached and their structures never built. + if (hit !== null) { + throw new Error(`heap probe '${probeTarget}' resolved for ${lang}; it must MISS: ${hit}`); + } + // Fails loudly if the corpus ever stops being one distinct path per file, + // which would silently shrink every reading here. + const size = pass.allFilePaths.size + (pass.config instanceof Set ? pass.config.size : 0); + if (size !== files.length) { + throw new Error(`heap arm corpus is not distinct: ${size} of ${files.length}`); + } + return Math.max(0, after - before); +} + +/** + * Every pass this arm builds, held alive ON PURPOSE until the next language + * starts. + * + * A `heapUsed` delta is only the new structures if nothing OLD is released + * between its two samples, and that is not a property a forced GC can be + * trusted to establish: measured, the previous read's index survived a + * two-cycle collect at the next read's baseline and was dropped by the collect + * before its second sample, so the two cancelled and the arm reported 249 200 B + * for a 9.3 MB index (PHP) and 329 064 B for a 6.7 MB one (JavaScript, once, + * non-reproducibly — the same defect with a different language's timing). + * + * Holding the passes removes the precondition instead of tuning it: nothing a + * measurement window depends on is ever collectable inside it, so the delta + * cannot absorb a late free no matter how many cycles the collector needs. + * Byte-identical readings at two and at four `gc()` cycles are the evidence + * that it works, where without it the two disagree by 9 MB. + * + * Emptied once per language, in `measureHeap`, which is the one place a late + * free is harmless: it happens before that language's first baseline and + * outside both of its measurement windows, and it is followed by a drain deeper + * than any chain here has needed. Never emptying at all also works and is what + * this was first measured with, but it peaks at ~380 MB and costs 4.5 s, + * because every forced collection from that point on has to mark it. + */ +const HEAP_RETAINED = []; + +/** + * The import each heap language resolves to force its build. A MISS in every + * case (asserted above), so the reading is the index and not a materialized + * answer, and so the cascade runs to completion instead of returning at the + * first leg. + * + * Each spelling is one the language's own corpus already mints in + * `uniqueTarget`, so the arm forces the same read pattern the timing arms do — + * which after #2903 is what decides the number: + * + * - `csharp` and `java` ask `index.get` and never `getInsensitive`, so the + * case-folded map is never built (49.6% of the eager Java index was dead); + * - `php` asks `getInsensitive` and never `get` (49.4% dead), and builds its + * own first-proper-suffix map on top; + * - `ruby` and the ts family read `get(s) || getInsensitive(s)`, so they pay + * for both — the second one DERIVED from the first, which is why they cost + * less than two independent traversals; + * - `csharp_csproj` additionally asks `getFilesInDir`, forcing the `dirMap` + * #2903 made lazy. It is the witness that the read pattern IS the + * footprint: same corpus and same `getWorkspaceFileIndex` as `csharp`, + * three times the retained bytes. + */ +const HEAP_PROBE_TARGET = { + csharp: 'Ghost0.Deep.Missing', + // Matches the `App` root namespace and no directory, so it runs the config + // loop's single-file leg (`get` + `getInsensitive`) AND its directory leg + // (`getFilesInDir`) before answering null — the three-map read pattern. + csharp_csproj: 'App.Missing0', + ruby: 'gem0/missing/thing', + // A mapped-but-missing class forces the Composer mapping and suffix-index + // read paths. The separate external probe below keeps the fast gate visible. + php: 'App\\HeapGhost0\\AbsentHeapProbe', + java: 'com.google.common.vendor0.Missing', + javascript: 'vendor0/lib/missing', + python: 'vendor0.deep.missing', + c: 'vendor0/missing.h', + // The entries below cover the BOUNDED tier — see `HEAP_BOUNDED`, which + // derives to cobol, swift and rust; the rest were promoted. Same rule as the + // budgeted ones above: a spelling `uniqueTarget` already mints for that language, and + // one that MISSES, so the reading is the index and the cascade runs to the + // end. Chosen from the miss family that reaches furthest into each cascade: + // - `go` names a missing package inside GO_MODULE, which reaches the + // package-directory lookup and forces `PackageDirIndex`; + // - `dart` is an external package, so BOTH candidate paths miss and both + // walk the basename bucket to completion; + // - `kotlin` misses after building its declared-package/module-binding index; + // - `cobol` misses in both tier maps, `swift` in `byModule`, and `rust` + // probes candidate paths and builds nothing — that last is the reading + // the exclusion rests on; + // - `typescript`, `vue` and `cpp` carry the same spelling shape as the + // `javascript` and `c` arms they are excluded as duplicates OF, so the + // bound compares like with like. `vue`'s is bare rather than `@/…` + // because the alias branch rewrites to `src/` and would resolve. + go: 'example.com/mod/repo0/pkg/util', + dart: 'package:ext0/src/thing.dart', + kotlin: 'com.ghost0.deep.Missing', + cobol: 'VENDOR0', + swift: 'ExternalPkg0', + rust: 'ghost0::Missing', + typescript: 'vendor0/lib/missing', + vue: 'vendor0/lib/Missing.vue', + cpp: 'vendor0/missing.hpp', +}; + +/** + * `buildFiles` mints every path with a template literal, and V8 represents + * those as ROPES — the concatenation is not materialized until something forces + * it. The first traversal that slices a path (`lastIndexOf('/')`, `toLowerCase`, + * every index builder here) flattens it, which allocates the flat string AND + * drops the rope's now-unreachable pieces, so a build measured over an + * unflattened corpus reports the index MINUS that net release: measured 11% + * low, uniformly, on every language whose index slices paths. + * + * It biased the arm in the one direction that matters. `bytes_small` was read + * over a corpus a discarded warm-up pass had already flattened and + * `bytes_large` over a fresh one, so every `ratio` here was ~0.85-0.89 for + * structures that are exactly linear in the file count — the ratio budget was + * bounding an artefact. Flattened first, all eight read 0.99-1.02. + * + * It also retires the warm-up pass, which was never about JIT: with the corpus + * flat, a language's first and second reads of the same file count agree to + * within 0.3%. + */ +function flatten(files) { + for (const file of files) file.lastIndexOf('/'); + return files; +} + +function measureHeap(lang) { + if (GC === null) return null; + // Release the PREVIOUS language's passes here and nowhere else, then drain + // them twice over. This is the one point at which a deferred collection is + // free: it is before this language's first baseline and outside both of its + // measurement windows, so however many cycles the release needs, it cannot + // land between a `before` and an `after`. + HEAP_RETAINED.length = 0; + GC(); + GC(); + const probe = HEAP_PROBE_TARGET[lang]; + const read = (files) => retainedPassBytes(lang, files, probe, lang === 'php' ? HEAP_PAD : 0); + const small = flatten(buildFiles(lang, HEAP_SMALL, HEAP_PAD, 'unique')); + const bytesSmall = read(small); + const large = flatten(buildFiles(lang, HEAP_LARGE, HEAP_PAD, 'unique')); + const bytesLarge = read(large); + const phpGateShape = + lang === 'php' + ? (() => { + const externalProbe = 'Vendor0\\Ghost\\Missing'; + const config = phpComposerConfigFor(HEAP_PAD); + const pass = newPass(lang, large, HEAP_PAD); + return { + resolution_config: renderPhpComposerConfig(config), + external_probe: externalProbe, + external_result: renderResolved(resolveOne(lang, large[0], externalProbe, pass)), + }; + })() + : {}; + return { + files_small: HEAP_SMALL, + files_large: HEAP_LARGE, + path_segments: small[0].split('/').length, + probe, + bytes_small: bytesSmall, + bytes_large: bytesLarge, + mib_large: Number((bytesLarge / 1024 / 1024).toFixed(2)), + ratio: Number((bytesLarge / bytesSmall / (HEAP_LARGE / HEAP_SMALL)).toFixed(3)), + ...phpGateShape, + }; +} + +/** + * The `context` arm's corpora — one per `CONTEXT_LANGS` entry, each a handful + * of files carrying ONE import whose answer DIFFERS between the production + * five-argument call and the three-argument one this harness used to make. + * + * That difference is the whole arm. PHP and Python need it because their main + * corpus answers agree with the fallback. Java and Kotlin deliberately return + * null without declared-package context; this tiny positive probe isolates the + * adapter contract from aggregate corpus changes. Timing cannot prove any of + * these; a dropped context makes the arms faster, and nothing here has a lower + * bound on ms. + * + * All probes are resolved THROUGH `resolveOne`, not through the resolvers directly, + * because what is under test is this file's threading rather than the + * resolvers' behaviour. The control differs in exactly one thing: + * `pass.parsedFiles` is undefined, which `contextFor` turns into no fifth + * argument at all. + */ +const CONTEXT_PROBE = { + /** + * `use function App\Ns0\Dup;` where the CLASS `Dup` lives in `Dup.php` and + * the FUNCTION `Dup` lives in `Helpers.php`. PHP keeps the two in separate + * symbol tables and PSR-4 maps only the class, which is the case the leg + * exists for: the suffix cascade answers the file whose NAME matches the last + * segment, the leg answers the file that DECLARES the function. Two distinct + * non-null paths, so neither half of the arm can be mistaken for a miss, and + * `Alpha.php` is a third file in the same directory so the candidate gather + * has something to reject. + */ + php: { + from: 'src/App/Ns0/Alpha.php', + target: 'App\\Ns0\\Dup', + parsedFiles: [ + probeFile('src/App/Ns0/Alpha.php', [['Class', 'App\\Ns0\\Alpha']]), + probeFile('src/App/Ns0/Dup.php', [['Class', 'App\\Ns0\\Dup']]), + probeFile('src/App/Ns0/Helpers.php', [['Function', 'App\\Ns0\\Dup']]), + ], + }, + /** A declared package resolves its type only when the parsed workspace arrives. */ + java: { + from: 'app/Main.java', + target: 'com.example.model.User', + parsedFiles: [ + javaProbeFile('app/Main.java', 'app'), + javaProbeFile('weird/path/User.java', 'com.example.model'), + ], + }, + /** A Kotlin export resolves from its package fact and module binding only. */ + kotlin: { + from: 'app/Main.kt', + target: 'com.example.model.User', + parsedFiles: [ + kotlinProbeFile('app/Main.kt', 'app', 'main'), + kotlinProbeFile('weird/path/UserSource.kt', 'com.example.model', 'User'), + ], + }, + /** + * `from pkg import X`, with `pkg/__init__.py` exporting `X` AND a same-named + * submodule `pkg/X.py` beside it — the precedence CPython documents and the + * one `pythonFileExportsName` exists to reproduce. With the parsed workspace + * the package's own export wins (`pkg/__init__.py`); without it the export is + * invisible, the submodule probe runs and `pkg/X.py` wins. + * + * `X` rather than a prettier name because `resolveOne` passes `importedName: + * 'X'`: the probe is tied to the spelling the timing arms use, so changing + * one without the other fails here. + * + * This corpus also catches a revert to the synthetic `namespace` spelling, + * which no exact-value assertion could: that spelling never reads + * `parsedFiles`, so BOTH halves answer `pkg/__init__.py` and the + * with/without inequality below is what notices. + */ + python: { + from: 'app/main.py', + target: 'pkg', + parsedFiles: [ + probeFile('pkg/__init__.py', [['Function', 'pkg.X']]), + probeFile('pkg/X.py', [['Function', 'pkg.X.run']]), + probeFile('app/main.py', [['Function', 'app.main.run']]), + ], + }, +}; + +/** Resolve the probe twice through `resolveOne` — once with the pass's parsed + * workspace, once without — and report both answers. Deterministic and + * microseconds, so it runs in report mode too. */ +function measureContext(lang) { + const { from, target, parsedFiles } = CONTEXT_PROBE[lang]; + const allFilePaths = new Set(parsedFiles.map((f) => f.filePath)); + const config = lang === 'php' ? phpComposerConfigFor(0) : undefined; + const answer = (files) => { + restoreBenchmarkSideChannels(lang, files ?? []); + return renderResolved( + resolveOne(lang, from, target, { allFilePaths, config, parsedFiles: files }), + ); + }; + return { + target, + with_context: answer(parsedFiles), + without_context: answer(undefined), + }; +} + +function fingerprint(outcomes) { + return crypto + .createHash('sha256') + .update([...outcomes].sort().join('\n')) + .digest('hex'); +} + +const CHECK = process.argv.includes('--check'); + +// The heap arm is a primary regression detector, but it can only be measured +// with a forced GC. Rather than let `--check` silently PASS with the heap gate +// skipped (a green no-op if someone drops --expose-gc), fail loudly. +if (CHECK && GC === null) { + process.stderr.write( + '[import-target --check] FAIL: the retained-heap arm requires --expose-gc. ' + + 'Run: node --expose-gc --import tsx bench/import-target/measure.mjs --check\n', + ); + process.exit(1); +} + +/** + * Every arm, and the registered language each one exercises. + * + * This used to be a hand-written list of language strings under a comment + * claiming it was "every language in `SCOPE_RESOLVERS`" — a claim nothing in + * the file could check, because the file never imported the registry. Adding a + * resolver to `pipeline/registry.ts` is two lines, neither of which is this + * one, so a newly registered language would have shipped ungated and + * printed PASS. That is not a hypothetical failure mode: JavaScript reached + * `suffixResolve` with no index at all and measured 25 972 µs per import at + * 8000 files (PR #2911) for exactly as long as nothing gated it. + * + * So the list is DERIVED and the claim is ASSERTED. `LANGS` is this table's + * keys, and the `--check` inventory arm below fails when a registered resolver + * has no arm here (or an arm names a language the registry does not have) — + * the same shape `test/unit/scope-resolution/import-target-index-reuse.contract.test.ts` + * uses ten files away, and the same "one row per language" table + * `bench/cfg/measure.mjs` keeps. + * + * The mapping is many-to-one only for C#: the configured arm reaches the + * csproj branch that the default arm cannot observe. PHP's sole arm carries + * its production Composer configuration directly. + */ +const LANG_REGISTRY = { + go: SupportedLanguages.Go, + csharp: SupportedLanguages.CSharp, + csharp_csproj: SupportedLanguages.CSharp, + dart: SupportedLanguages.Dart, + ruby: SupportedLanguages.Ruby, + kotlin: SupportedLanguages.Kotlin, + php: SupportedLanguages.PHP, + java: SupportedLanguages.Java, + cobol: SupportedLanguages.Cobol, + swift: SupportedLanguages.Swift, + rust: SupportedLanguages.Rust, + python: SupportedLanguages.Python, + javascript: SupportedLanguages.JavaScript, + typescript: SupportedLanguages.TypeScript, + vue: SupportedLanguages.Vue, + c: SupportedLanguages.C, + cpp: SupportedLanguages.CPlusPlus, +}; +const LANGS = Object.keys(LANG_REGISTRY); +/** + * The heap arm's SECOND tier: every arm that is not budgeted, and the reason it + * is a `filter` over `LANGS` rather than a second list beside `HEAP_BUDGETED`. + * + * The two tiers partition `LANGS` by construction, so there is no third state a + * language can be in — the state the nine spent this file's whole life in, + * where "not budgeted" and "not measured" were the same thing and neither was + * derived from anything. Adding a registered language now costs a bound whether + * or not anyone thinks about memory: the inventory arm gives it a `LANGS` row, + * this line gives it a tier, and the presence check below fails until it has a + * key. Deriving it also means the two tiers cannot overlap or leave a gap, which + * two hand-written lists could do in either direction. + * + * A bound and NOT a floor, deliberately, and the boundary is the one thing here + * worth re-reading before moving a language across it: a floor asserts "this + * arm is still measuring something", which is a claim about an index the file + * has budgeted, and rust's 16 B cannot carry it. What every one of the nine CAN + * carry is "the exclusion still holds" — that this language has not grown an + * index since it was left out. See the TIER TWO loop at the foot of the file, + * and `_heap_bound_note` in baselines.json for each language's reason. + */ +const HEAP_BOUNDED = LANGS.filter((lang) => !HEAP_BUDGETED.includes(lang)); +/** name, file count, depth padding, directory/basename layout. */ +const ARMS = [ + ['small', SMALL, 0, 'unique'], + ['large', LARGE, 0, 'unique'], + ['deep', SMALL, DEEP_PAD, 'unique'], + ['collide', SMALL, 0, 'collide'], + ['collide_large', LARGE, 0, 'collide'], +]; +/** Derived, never hand-written: the shape/fingerprint gate below iterates these + * names, so a new arm is asserted by construction rather than measured, + * printed and silently left out of the gate. */ +const SCALES = ARMS.map(([name]) => name); +const report = {}; +for (const lang of LANGS) { + const scales = {}; + // Sized once per language, from the FIRST arm — `small`, the cheapest — so + // all five arms share one estimator and the four ratios below stay + // comparisons of like with like. See `repsFor`. + let reps = null; + for (const [name, fileCount, pad, shape] of ARMS) { + const { files, imports } = buildRepo(lang, fileCount, pad, shape); + const { outcomes, resolved } = identityPass(lang, files, imports, pad); + if (reps === null) reps = repsFor(probeMs(lang, files, imports, pad)); + scales[name] = { + files: files.length, + imports: imports.length, + // Reported, not asserted on its own: a corpus edit that collapsed the + // resolved share would still produce a "valid" fingerprint over far less. + resolved, + distinct_outcomes: outcomes.size, + ms: Number(timeResolution(lang, files, imports, reps, pad).toFixed(3)), + fingerprint: fingerprint(outcomes), + }; + } + report[lang] = { + ...scales, + // Reported so a triager can see which estimator produced the five ms + // numbers above; environment-derived, so never asserted. + reps, + scaling_ratio: Number((scales.large.ms / scales.small.ms / (LARGE / SMALL)).toFixed(3)), + // `scaling_ratio` divides the file count out, so it is scale-invariant and + // structurally cannot see a cost that grows with path DEPTH instead — and + // `buildSuffixIndex` (C#, Ruby, PHP, Java, and the whole ts family) emits + // one entry per '/' in a path, while Kotlin's declared-package index is + // depth-free, and + // Python's ancestor walk rebuilds one prefix per component PER IMPORT. + // Same file count, ~6x the components. + depth_ratio: Number((scales.deep.ms / scales.small.ms).toFixed(3)), + // Same measurement on the shared-leaf layout. Legitimately above the 1.8 + // budget for go/csharp/dart — see the scope-of-claim note in the header. + collide_scaling_ratio: Number( + (scales.collide_large.ms / scales.collide.ms / (LARGE / SMALL)).toFixed(3), + ), + fingerprint: scales.large.fingerprint, + }; +} + +// AFTER every timing arm, never interleaved with them, and now for a second +// reason as well as the first. The first: the heap arm allocates a 32k-path +// corpus and a ~70 MiB index per language, and leaving that behind for the next +// language's timed loop to collect would tax an arm it has nothing to do with. +// The second: `HEAP_RETAINED` holds a language's whole corpus and index alive +// across both of its reads — up to ~92 MiB for `csharp_csproj` — and that must +// not overlap a measurement of time. +// +// `LANGS`, not `HEAP_BUDGETED`: which tier a language is in decides its GATE, +// not whether it is read. Measured cost of the nine extra arms is 1.37 s — this +// phase goes 2.06 s -> 3.43 s, of which kotlin alone is 0.57 s. See COST. +for (const lang of LANGS) report[lang].heap = measureHeap(lang); + +// Deterministic and microseconds — it resolves six imports over three tiny +// corpora — so unlike the heap arm it neither needs nor deserves isolation from +// the timing phase. It runs last only because it reads best beside the heap arm +// in the report. +for (const lang of CONTEXT_LANGS) report[lang].context = measureContext(lang); + +if (!CHECK) { + console.log(JSON.stringify(report, null, 2)); + process.exit(0); +} + +const baseline = JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf-8')); +const failures = []; + +/** + * PRESENCE, for one budget, in the one place that spells the reason. + * + * A missing budget is a DELETED GATE, not a passing arm: `got > undefined` is + * `false`, `ceiling * undefined` is `NaN` and `bytes < NaN` is `false`, so every + * comparison in this file answers "within budget" for every possible + * measurement the moment its key stops being a number. Each of the three call + * sites below is one deleted key away from a silent no-op, and the run still + * prints PASS. + * + * `Number.isFinite` rather than `typeof === 'number'`: over JSON input the two + * agree (JSON cannot express NaN or Infinity), and the stricter one is the one + * whose name says what the gate needs. + * + * The two per-site facts stay the caller's, because they are what a triager acts + * on: `reads` is the comparison that silently stopped gating, quoted, and + * `scope` is what deleting this one key actually costs — a single arm, or all + * eight at once. Only the shared framing and the shared trailing sentence live + * here. Returns the message rather than pushing it, so the timing loop can + * `continue` past a budget it must not then compare against. + */ +const requireNumericBudget = ({ key, value, reads, scope }) => + Number.isFinite(value) + ? null + : `no numeric ${key} in baselines.json — a missing budget is a DELETED GATE, not a passing ` + + `arm: the comparison it gates reads \`${reads}\`, which is false for every possible ` + + `measurement. ${scope} Deterministic: a re-run will not change it.`; + +/** + * The REVERSE direction of a reconciliation: every key declared in `label` that + * `codeList` does not name. + * + * The forward direction ("the code has an arm with no budget") is a presence + * check inside whichever loop iterates the code's list. This is the other way + * round — a budget, a baseline block or a registry row for an arm that is never + * measured — and no forward check can see it, because the thing it names is + * exactly the thing nothing iterates. + * + * `codeListName` and `why` stay the caller's: which list is authoritative and + * what the orphan costs are the two facts that differ between the three arms, + * and flattening them would leave a triager with a name and no reading of it. + */ +function expectNoOrphanKeys(label, declaredKeys, codeList, codeListName, why) { + for (const key of declaredKeys) { + if (codeList.includes(key)) continue; + failures.push( + `${label} has an entry for '${key}', which is not in ${codeListName} — ${why} ` + + `Deterministic: a re-run will not change it.`, + ); + } +} + +/** The corpus-shape facts asserted for one timing scale. */ +const SCALE_SHAPE = { + fields: ['files', 'imports', 'resolved', 'distinct_outcomes', 'fingerprint'], + why: + 'the corpus changed shape or the resolver changed its answer for this arm. Every scale is ' + + 'asserted separately: the arms differ only in padding and layout, so a defect that touches ' + + 'one of them alone moves nothing in the others.', +}; +/** The same, for the heap arm — the four inputs that decide what it measures. + * Asserted for all seventeen arms, budgeted tier and bounded tier alike, and it is + * the bounded tier that needs it most: a bound is a single comparison, so a + * probe swapped for one that reaches less is a bound over a smaller workload + * and there is no floor beside it to notice. + * `bytes_small`/`bytes_large` are deliberately NOT here: they are bounded by + * `heap_ceiling_bytes` and `heap_reading_bytes` with ~50% of slack either way + * (`heap_bound_bytes` with 50% on the one side), because a Node major or a + * different platform moves heapUsed accounting and an exact-equality arm on a + * byte count would be a re-baseline per runner. */ +const HEAP_SHAPE = { + fields: ['files_small', 'files_large', 'path_segments', 'probe'], + why: + 'these four decide WHAT the heap arm measures and nothing else here can see them move — a ' + + 'probe that stops reaching a leg, or two file counts collapsed onto one, leaves every ' + + 'ceiling, floor, bound and ratio passing over an arm that changed workload. Deterministic: ' + + 'a re-run will not change it.', +}; +const PHP_HEAP_SHAPE = { + fields: [...HEAP_SHAPE.fields, 'resolution_config', 'external_probe', 'external_result'], + why: + HEAP_SHAPE.why + + ' PHP also pins the Composer mapping and a suffix-matchable external decoy so the mapped ' + + 'index path and the external fast gate remain separate observable arms.', +}; +/** The same, for the `context` arm. All three fields are exact strings, not + * bounds: this arm has no measurement noise at all — it resolves one import + * two ways over a three-file corpus — so anything less than equality would be + * slack for nothing. */ +const CONTEXT_SHAPE = { + fields: ['target', 'with_context', 'without_context'], + why: + 'the fifth `context` argument stopped reaching this resolver, reached it in a different ' + + 'shape, or the resolver changed what it does with it. `with_context` is what the five-argument ' + + 'call `run.ts` makes answers and `without_context` is what the three-argument one this bench ' + + 'used to make answers; both are pinned, so a change is attributed rather than guessed. ' + + 'Deterministic: a re-run will not change it.', +}; +/** Every asserted arm for one language, derived so a new scale is covered by + * construction. The heap arm is present for EVERY language now — it used to be + * conditional on `HEAP_LANGS`, which is what let the other nine be measured by + * nothing and pinned by nothing; the two tiers below decide which gate the + * reading gets. The context arm is still conditional, on `CONTEXT_LANGS`, which + * the registry-arity arm at the foot of the file pins to the hooks that DECLARE + * a fifth parameter. */ +const armShapes = (lang) => [ + ...SCALES.map((scale) => [scale, SCALE_SHAPE]), + ['heap', lang === 'php' ? PHP_HEAP_SHAPE : HEAP_SHAPE], + ...(CONTEXT_LANGS.includes(lang) ? [['context', CONTEXT_SHAPE]] : []), +]; + +for (const lang of LANGS) { + const got = report[lang]; + const want = baseline.languages[lang]; + if (got.fingerprint !== want.fingerprint) { + failures.push( + `${lang}: fingerprint drift ${got.fingerprint} != ${want.fingerprint} — the resolver ` + + `returned a DIFFERENT target set. That is a behaviour change, not a perf one; see the ` + + `parity harnesses in test/unit/scope-resolution/*-import-target-parity.test.ts and the ` + + `all-languages adapter guard in import-target-index-reuse.contract.test.ts.`, + ); + } + // One shape, five facts, so the five budgets read side by side and the shared + // trailing sentence exists once instead of drifting into five wordings. Each + // `why` stays the arm's OWN: it is what tells a triager which corpus shape + // regressed, and flattening it would cost the message its whole value. + // `key` is the baselines.json path the budget came from, so the presence + // check below can name it. + const timingChecks = [ + { + label: 'scaling', + key: 'scaling_budget', + got: got.scaling_ratio, + budget: baseline.scaling_budget, + why: 'per-import cost grows with corpus size again.', + }, + { + label: 'depth', + key: `depth_budget.${lang}`, + got: got.depth_ratio, + budget: baseline.depth_budget?.[lang], + why: + 'cost grows with path DEPTH at a fixed file count, which scaling_ratio divides out and ' + + 'cannot see.', + }, + { + label: 'collide scaling', + key: `collide_scaling_budget.${lang}`, + got: got.collide_scaling_ratio, + budget: baseline.collide_scaling_budget?.[lang], + why: + 'on the SHARED-LEAF layout (svcN/internal, SrcN/Models, a repeated basename per package) ' + + 'per-import cost grew beyond what this shape already costs by construction.', + }, + { + label: 'small arm ms', + key: `small_ms_ceiling.${lang}`, + got: got.small.ms, + budget: baseline.small_ms_ceiling?.[lang], + why: + 'an ABSOLUTE bound, because a constant-factor regression that grows both arms equally ' + + 'passes the ratio.', + }, + { + label: 'collide arm ms', + key: `collide_ms_ceiling.${lang}`, + got: got.collide.ms, + budget: baseline.collide_ms_ceiling?.[lang], + why: 'the ABSOLUTE bound on the shared-leaf layout.', + }, + ]; + for (const check of timingChecks) { + // PRESENCE FIRST — see `requireNumericBudget` for why. All five maps are + // complete today, which is exactly when the check is worth having: every one + // of the four per-language lookups above is one deleted key away from a + // silent no-op. The heap arm HAD THE SAME HOLE and the comment here used to + // deny it: iterating the BASELINE's keys protects that loop against a + // deleted MEASUREMENT, which is a different thing from a deleted BUDGET. + // See `heapBudgetChecks`. + const missing = requireNumericBudget({ + key: check.key, + value: check.budget, + reads: `${check.got} > undefined`, + scope: `That leaves ${lang}'s ${check.label} arm ungated.`, + }); + if (missing !== null) { + failures.push(`${lang}: ${missing}`); + continue; + } + if (check.got > check.budget) { + failures.push( + `${lang}: ${check.label} ${check.got} > budget ${check.budget} — ${check.why} ` + + `Timing arm: re-run on an idle machine before investigating.`, + ); + } + } + for (const arm of ['deep', 'collide']) { + if (got[arm].resolved !== got.small.resolved) { + failures.push( + `${lang}: ${arm} arm resolved ${got[arm].resolved} vs small ${got.small.resolved} — the ` + + `${arm} arm was supposed to change ${arm === 'deep' ? 'path depth' : 'directory and file NAMING'} ` + + `and nothing else, so that it times the same workload. An arm that stopped resolving ` + + `would be timing the null path and its ratio would mean nothing.`, + ); + } + // Count-neutral by design, so neutering the arm (DEEP_PAD = 0, a collideDir + // that forwards to uniqueDir) moves NO asserted count. Comparing the two + // fingerprints is the only arm that notices. + if (got[arm].fingerprint === got.small.fingerprint) { + failures.push( + `${lang}: ${arm}.fingerprint equals small.fingerprint — the ${arm} arm is resolving the ` + + `IDENTICAL corpus, so it measures nothing. ` + + `${arm === 'deep' ? 'DEEP_PAD is 0 or the padding stopped reaching buildFiles' : 'collideDir is returning the uniqueDir layout'}. ` + + `This is a deterministic arm: a re-run will not change it.`, + ); + } + } + // The `context` arm's own discriminator, and the same shape of gate as the + // deep/collide fingerprint comparison above: `armShapes` pins WHAT the two + // call shapes answer, and this pins that they still answer DIFFERENTLY. + // Without it the arm degrades exactly the way `DEEP_PAD = 0` degrades the + // depth arm — a probe on which both halves agree asserts two copies of one + // number. Deleting the fifth argument from `resolveOne`, deleting + // `importedSymbolKind` from PHP's import, or reverting Python to the + // `namespace` spelling all land here, and NOTHING else in this file would + // notice: on the main corpus the leg agrees with the cascade, so the + // fingerprints do not move, and a dropped context only makes the timing arms + // faster. + if (CONTEXT_LANGS.includes(lang) && got.context.with_context === got.context.without_context) { + failures.push( + `${lang}: context arm answers '${got.context.with_context}' with AND without the pass's ` + + `parsedFiles — the fifth argument is not reaching the resolver, or the leg behind it no ` + + `longer runs (PHP needs parsedImport.kind named|alias AND importedSymbolKind ` + + `function|const; Python needs named|alias, since a namespace import never reads ` + + `parsedFiles). run.ts calls resolveImportTarget with five arguments and this bench must ` + + `too. Deterministic: a re-run will not change it.`, + ); + } + // ONE loop for every arm's corpus shape, timing and heap alike. The heap arm + // was reported here and asserted nowhere, which made the four fields that + // decide WHAT it measures free to move: `HEAP_PROBE_TARGET.csharp_csproj` + // swapped for a target matching no `CSPROJ_CONFIGS` rootNamespace skips the + // whole config loop, so the `getFilesInDir` and `getInsensitive` legs never + // run, and the arm the MEMORY section calls "the witness that the read + // pattern IS the footprint" quietly becomes a two-map arm — measured + // 73 703 384 -> 59 921 216 B, ratio 1.017 -> 1.011, ceiling and floor both + // still passing. `HEAP_SMALL` set equal to `HEAP_LARGE` is the same shape of + // hole: it makes `ratio` identically ~1.0 and leaves `bytes_large` untouched. + for (const [arm, shape] of armShapes(lang)) { + for (const field of shape.fields) { + if (got[arm][field] !== want[arm]?.[field]) { + failures.push( + `${lang}.${arm}.${field}: ${got[arm][field]} != ${want[arm]?.[field]} — ${shape.why}`, + ); + } + } + } +} + +// PRESENCE FIRST for the two SCALAR heap budgets, for exactly the reason the +// five timing budgets get it — and the reason the comment up there used to give +// for the heap arm not needing it was wrong. Iterating the baseline's keys +// protects the loop below against a deleted MEASUREMENT (`heap == null`, right +// there); it does nothing about a deleted BUDGET. These two keys are scalars +// rather than per-language maps, so deleting either is one keystroke that +// silently disables that arm for ALL EIGHT languages at once. That makes them +// the widest-blast-radius keys in this file, not the safest — which is what +// their `scope` sentence says and the per-language ones do not. +const heapBudgetChecks = [ + { key: 'heap_floor_fraction', value: baseline.heap_floor_fraction, reads: 'bytes_large < NaN' }, + { key: 'heap_ratio_budget', value: baseline.heap_ratio_budget, reads: 'ratio > undefined' }, +]; +const heapArmScope = `This one key gates all ${HEAP_BUDGETED.length} budgeted heap arms at once.`; +for (const check of heapBudgetChecks) { + const missing = requireNumericBudget({ ...check, scope: heapArmScope }); + if (missing !== null) failures.push(missing); +} + +// And EXACT KEY EQUALITY between each tier's CODE list and the baseline maps +// that gate it, because the loops below iterate the baseline: delete one +// language's ceiling and that language drops out of the loop entirely — still +// measured, still printed, never checked. Both directions, the same shape as the +// LANG_REGISTRY/SCOPE_RESOLVERS inventory arm at the bottom of the file. The +// forward direction (a language with no budget) is the per-language presence +// check inside each loop; this is the reverse (a budget with no arm). +// +// Three maps rather than two: `heap_bound_bytes` is reconciled against +// `HEAP_BOUNDED` exactly as the other two are against `HEAP_BUDGETED`, so a +// language promoted from bounded to budgeted has to move its key in the same +// edit — leave the bound behind and it is an orphan here, take the bound away +// without adding a ceiling and the presence check fires there. +const heapBudgetMaps = [ + ['heap_ceiling_bytes', baseline.heap_ceiling_bytes, HEAP_BUDGETED, 'HEAP_BUDGETED'], + ['heap_reading_bytes', baseline.heap_reading_bytes, HEAP_BUDGETED, 'HEAP_BUDGETED'], + ['heap_bound_bytes', baseline.heap_bound_bytes, HEAP_BOUNDED, 'HEAP_BOUNDED'], +]; +for (const [key, map, codeList, codeListName] of heapBudgetMaps) { + expectNoOrphanKeys( + `baselines.json ${key}`, + Object.keys(map ?? {}), + codeList, + codeListName, + 'the bench budgets a heap arm it does not measure.', + ); +} + +// The two heap tiers are a PARTITION of LANGS by construction (`HEAP_BOUNDED` +// is a filter over it), so the only way a name can be in neither is for +// `HEAP_BUDGETED` to hold one `LANGS` does not — a typo, or a language dropped +// from the registry with its budget left behind. That name would then be +// measured by nothing, and the loop below would report it as a missing arm +// without ever saying why; this says why. +expectNoOrphanKeys( + 'HEAP_BUDGETED', + HEAP_BUDGETED, + LANGS, + 'LANGS', + 'that name is in neither heap tier, because HEAP_BOUNDED is derived as the languages LANGS ' + + 'has and this list does not — so its budget gates nothing and its language, if it has one, ' + + 'is bounded by nothing.', +); +// The same, for the probe map. The forward direction — a language with no probe +// — is caught by the `heap.probe` shape assertion (`undefined` never equals a +// recorded string), so what is left is a probe kept for an arm that no longer +// runs, which reads as coverage and is not. +expectNoOrphanKeys( + 'HEAP_PROBE_TARGET', + Object.keys(HEAP_PROBE_TARGET), + LANGS, + 'LANGS', + 'the bench carries a heap probe for a language it does not benchmark.', +); + +// The same reverse direction for the context arm. The forward direction (a +// language in CONTEXT_LANGS with no baseline block) is `armShapes`, which +// compares against `want.context?.[field]` and fails on undefined; this is the +// other way round — a baseline block for a language the bench hands no context +// is a gate over an arm that is never measured, and `armShapes` would never +// look at it. +expectNoOrphanKeys( + 'baselines.json languages.*.context', + Object.keys(baseline.languages).filter((lang) => baseline.languages[lang].context !== undefined), + CONTEXT_LANGS, + 'CONTEXT_LANGS', + 'the bench pins an arm it does not run.', +); + +// TIER ONE, the budgeted arms: ceiling, floor and ratio, all three unchanged. +// +// Driven by HEAP_BUDGETED, the CODE's list, exactly as the timing arms iterate +// LANGS — so a deleted budget key is a presence failure rather than a language +// that quietly stops being iterated. A deleted MEASUREMENT still fails too: +// `measureHeap` now runs for every language, so a `heap == null` here is the arm +// having been removed or skipped. +for (const lang of HEAP_BUDGETED) { + const ceiling = baseline.heap_ceiling_bytes?.[lang]; + const reading = baseline.heap_reading_bytes?.[lang]; + // `reads` names the comparison each key gates further down: the ceiling is + // compared directly, the reading only after `reading * heap_floor_fraction` + // has turned a missing one into `NaN`. + for (const [key, value, reads] of [ + ['heap_ceiling_bytes', ceiling, 'bytes_large > undefined'], + ['heap_reading_bytes', reading, 'bytes_large < NaN'], + ]) { + const missing = requireNumericBudget({ + key: `${key}.${lang}`, + value, + reads, + scope: + `This loop iterates HEAP_BUDGETED precisely so that deleting the key fails here instead ` + + `of dropping ${lang} out of the gate.`, + }); + if (missing !== null) failures.push(`${lang}: ${missing}`); + } + const heap = report[lang]?.heap; + if (heap == null) { + failures.push( + `${lang}: heap arm missing though HEAP_BUDGETED names it — the retained-index measurement ` + + `was removed or skipped. It is the only arm that can see memory.`, + ); + continue; + } + if (heap.bytes_large > ceiling) { + failures.push( + `${lang}: retained per-pass import index ${heap.mib_large} MiB at ${heap.files_large} ` + + `files (${heap.bytes_large} B) > ceiling ${ceiling} B — these indexes are built at ` + + `O(files × depth) and this is the ABSOLUTE bound on that (#2649). Deterministic: a ` + + `re-run will not change it.`, + ); + } + // A FLOOR as well as a ceiling, and it is the arm that would have caught the + // one defect this whole block exists for. When `buildSuffixIndex` went lazy, + // these four arms stopped asking a suffix question, built no map and reported + // 0 B at 32 000 files — and 0 B is under every ceiling, so `--check` printed + // PASS over four gates that had become ceilings over nothing. A ceiling can + // only ever say "not too big"; nothing said "still measuring something". + // + // Taken as a fraction of the RECORDED READING, not of the ceiling. It used to + // be 0.33 x the ceiling, with the comment claiming that put it "at half the + // measured size" — true only for as long as every ceiling stayed at exactly + // 1.5x its reading, which is a convention this file states and nothing + // enforces. Re-tuning one ceiling upward would have loosened that language's + // floor by the same factor, in the one direction the floor exists to watch. + // 0.5 x the reading is the same effective floor today (within 0.8% for all + // eight) and says what it means. `heap_reading_bytes` is the measurement the + // ceiling is derived from too, so the pair still moves together on a + // re-baseline — far below any plausible drift (the readings reproduce to the + // byte across processes) and far above the collapse it watches for. A genuine + // 2x memory WIN trips it too, and that is intended: it must be explained and + // re-baselined, exactly like a fingerprint move. + const floor = reading * baseline.heap_floor_fraction; + if (heap.bytes_large < floor) { + failures.push( + `${lang}: retained per-pass import index ${heap.bytes_large} B at ${heap.files_large} ` + + `files < floor ${Math.round(floor)} B (${baseline.heap_floor_fraction} x recorded ` + + `reading ${reading}) — this arm has almost certainly stopped MEASURING rather than started ` + + `saving. Probe '${heap.probe}' resolves through the real resolver; if a leg it used to ` + + `reach now returns earlier, or an index it forced is now built lazily behind a question ` + + `nobody asks, the arm reads ~0 and every ceiling above passes. Deterministic: a re-run ` + + `will not change it.`, + ); + } + if (heap.ratio > baseline.heap_ratio_budget) { + failures.push( + `${lang}: retained-heap ratio ${heap.ratio} > budget ${baseline.heap_ratio_budget} ` + + `(${heap.bytes_small} B at ${heap.files_small} files -> ${heap.bytes_large} B at ` + + `${heap.files_large}) — the index stopped growing linearly in the file count.`, + ); + } +} + +/** + * TIER TWO, the bounded arms: ONE comparison, and what it is a comparison FOR. + * + * `heap_bound_bytes` is the "exclusion still holds" bound. It does not claim + * these indexes are small enough, which is what a ceiling claims about a + * budgeted one; it claims each is still the SIZE the decision to leave it out + * was taken on. `HEAP_BOUNDED` derives to THREE today — cobol, swift, rust. + * The prose below still counts nine because six were promoted to tier one + * after it was written; read the counts as history, and `HEAP_BOUNDED` itself + * as the answer. The re-entry condition the MEMORY section states — "if any of + * the four ever diverges in what it ASKS, it earns an arm the same way" — is a + * claim about growth, and this is the only thing in the file that can see it. + * + * NO FLOOR, and the reason is per language rather than uniform. rust reads 16 B + * because it builds nothing, so any floor at all would be a floor on noise and + * `1.5 x 0 B` is 0 — its bound is ABSOLUTE (1 MiB) for the same reason: a + * multiplier on 16 B fails on the first byte of anything. The other eight are + * stable enough today to floor (0.24% peak-to-peak at worst over five runs). + * The two this paragraph named as floor candidates, kotlin and dart, TOOK that + * promotion: both now carry a ceiling and a recorded reading in tier one, which + * is what the paragraph said the promotion had to be. What this tier is NOT is a + * weaker version of tier one — it is a different question, asked of the + * languages tier one does not ask it of. + */ +const heapBoundScope = + `That leaves the arm bounded by nothing, which is the state all nine of these were in before ` + + `they were measured.`; +for (const lang of HEAP_BOUNDED) { + const bound = baseline.heap_bound_bytes?.[lang]; + const missing = requireNumericBudget({ + key: `heap_bound_bytes.${lang}`, + value: bound, + reads: 'bytes_large > undefined', + scope: heapBoundScope, + }); + if (missing !== null) failures.push(`${lang}: ${missing}`); + const heap = report[lang]?.heap; + if (heap == null) { + failures.push( + `${lang}: heap arm missing though HEAP_BOUNDED names it — every registered language is ` + + `measured now, and the tier only decides which gate the reading gets.`, + ); + continue; + } + if (missing === null && heap.bytes_large > bound) { + failures.push( + `${lang}: retained per-pass import index ${heap.mib_large} MiB at ${heap.files_large} ` + + `files (${heap.bytes_large} B) > bound ${bound} B — this language is EXCLUDED from the ` + + `budgeted heap tier, and the bound is what says the exclusion still holds. It has grown ` + + `a structure, or started asking its index a question it did not ask when the exclusion ` + + `was recorded. Read _heap_bound_note in baselines.json for this language's reason and ` + + `its recorded reading, then either explain the growth or promote it to HEAP_BUDGETED ` + + `with a ceiling, a reading and a floor. Deterministic: a re-run will not change it.`, + ); + } +} + +// INVENTORY, the arm that makes "every registered language is gated" a checked +// claim instead of a comment. `LANG_REGISTRY` is a hand-written table — it has +// to be, since each row also implies five dispatcher branches — but which +// languages it must contain is not a judgement call, and this is where the two +// are reconciled. Both directions: a resolver registered with no arm here is +// the PR #2911 hole (a language shipping unmeasured), and an arm naming a +// language the registry does not have is a bench measuring something the +// pipeline no longer runs. +// +// Loaded HERE, after the last measurement, rather than imported at the top. +// Reaching `pipeline/registry.ts` drags in every registered scope resolver and +// its providers, and this arm is the only thing in the file that wants it. The +// side benefit is that both modes now measure in the same module state: report +// mode never loads the registry, and `--check` loads it only once every number +// has been taken. +// +// It is NOT cheap and the header says so plainly rather than rounding it down: +// 6.3-6.5 s on one box and 9.3-10.0 s on another, measured in isolation with +// this file's own static imports already resident, which is most of the +// `repsFor` win and the whole reason `--check` did not get faster. Kept anyway, +// because the `benchmarks` job runs ~4.5 minutes clear of CI's critical path, +// so the seconds buy nothing, and because the alternative reconciles arm NAMES +// where this reconciles the `SupportedLanguages` values the dispatchers key +// off. See COST in the header. +const { SCOPE_RESOLVERS } = + await import('../../src/core/ingestion/scope-resolution/pipeline/registry.ts'); +const registeredLanguages = [...SCOPE_RESOLVERS.keys()].sort(); +const benchedLanguages = [...new Set(Object.values(LANG_REGISTRY))].sort(); +for (const language of registeredLanguages) { + if (benchedLanguages.includes(language)) continue; + failures.push( + `${language} is registered in SCOPE_RESOLVERS but has no arm in LANG_REGISTRY — its ` + + `import-target resolver is ungated: nothing pins its output and nothing pins its scaling. ` + + `That is the state JavaScript was in at 25 972 µs per import (PR #2911). Add a row, then ` + + `the five dispatcher branches it needs (uniqueDir, collideDir, uniqueTarget, collideTarget, ` + + `resolveOne) and a baselines.json entry. Deterministic: a re-run will not change it.`, + ); +} +// The reverse half is the same loop as the two above it, so it goes through the +// same helper. Only the FORWARD half stays written out: its message is a +// five-step remediation for adding a language, which no shared framing carries. +expectNoOrphanKeys( + 'LANG_REGISTRY', + benchedLanguages, + registeredLanguages, + 'SCOPE_RESOLVERS', + 'this bench is gating a resolver the pipeline no longer registers.', +); + +// The SAME reconciliation for `CONTEXT_LANGS`, against the registry rather than +// against a claim in a comment. `run.ts` passes the fifth argument to every +// provider; which ones can OBSERVE it is decided by how many parameters each +// hook declares, and that is a number the registry can be asked for. Today +// exactly four answer 5 (php, java, kotlin, python) and the other thirteen answer 3 or 4 — +// which is why thirteen arms can ignore this whole question and their numbers +// did not move when it was fixed. +// +// `Function.length` stops at the first defaulted or rest parameter, so a hook +// written as `(a, b, c, d, context = {})` would read 4 and slip past this arm. +// The shared contract declares the parameter as `context?:`, which compiles to +// a plain parameter, so every resolver written against it counts — and one that +// is not is one this arm asks you to look at. +const CONTEXT_PARAM_COUNT = 5; +const contextLanguages = new Set(CONTEXT_LANGS.map((lang) => LANG_REGISTRY[lang])); +for (const [language, resolver] of SCOPE_RESOLVERS) { + const declares = resolver.resolveImportTarget.length >= CONTEXT_PARAM_COUNT; + const benched = contextLanguages.has(language); + if (declares === benched) continue; + failures.push( + declares + ? `${language}'s resolveImportTarget declares ${resolver.resolveImportTarget.length} ` + + `parameters, so it can read the { parsedFiles, parsedImport } context run.ts passes, ` + + `but no arm here supplies one — that leg is measured by nothing. Add the language to ` + + `CONTEXT_LANGS, thread the context in resolveOne, and give it a CONTEXT_PROBE whose ` + + `two answers differ. Deterministic: a re-run will not change it.` + : `CONTEXT_LANGS names '${language}', whose resolveImportTarget declares only ` + + `${resolver.resolveImportTarget.length} parameters — it cannot observe a context, so ` + + `this bench is building a ParsedFile[] per pass that nothing reads and asserting a ` + + `context arm that cannot fail. Deterministic: a re-run will not change it.`, + ); +} + +console.log(JSON.stringify(report, null, 2)); +if (failures.length > 0) { + console.error(`[import-target --check] FAIL\n - ${failures.join('\n - ')}`); + process.exit(1); +} +console.log('[import-target --check] PASS'); diff --git a/gitnexus/bench/java-lombok-synthesis/baselines.json b/gitnexus/bench/java-lombok-synthesis/baselines.json new file mode 100644 index 000000000..9e14f8f3c --- /dev/null +++ b/gitnexus/bench/java-lombok-synthesis/baselines.json @@ -0,0 +1,8 @@ +{ + "_comment": "Baselines for bench/java-lombok-synthesis/measure.mjs --check (#2885). fingerprint is sha256 over synthetic Method node ids on the lombok_large corpus (800 @Data entities × 4 fields × 2 accessors = 6400 methods). no_lombok arm must emit 0 methods. Budgets are timing gates with CI headroom.", + "fingerprint": "b935d6894d32de7594d5887bb62af6ade2b66b19d6846700a05ef3baf1ed1eb1", + "scaling_budget": 1.6, + "_scaling_note": "(t_large/t_small)/(800/250) on the lombok arm. Measured ~1.01.", + "widening_overhead_budget": 2.5, + "_widening_overhead_note": "lombok_large_ms / no_lombok_large_ms using an unannotated, shape-equivalent four-field control. Measured about 1.24; budget guards against a pathological feature-arm regression." +} diff --git a/gitnexus/bench/java-lombok-synthesis/measure.mjs b/gitnexus/bench/java-lombok-synthesis/measure.mjs new file mode 100644 index 000000000..512af1e12 --- /dev/null +++ b/gitnexus/bench/java-lombok-synthesis/measure.mjs @@ -0,0 +1,124 @@ +/** + * Build-free throughput + identity bench for Java Lombok accessor synthesis. + * + * Arms: + * - no_lombok: unannotated fields (shape-equivalent control) — synthesizer no-ops + * - lombok_heavy: @Data classes (feature path) + * + * Times synthesizeLombokAccessors over N separate files (not one giant buffer). + * + * Usage: + * node --import tsx bench/java-lombok-synthesis/measure.mjs + * node --import tsx bench/java-lombok-synthesis/measure.mjs --check + */ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import Parser from 'tree-sitter'; +import Java from 'tree-sitter-java'; +import { synthesizeLombokAccessors } from '../../src/core/ingestion/languages/java/lombok-synthesizer.ts'; +import { + fingerprintIds, + minSample, + runBaselineCheck, + runMethodCountCheck, +} from '../lib/identity-guard.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const BASELINE_PATH = path.resolve(__dirname, 'baselines.json'); + +const SMALL = 250; +const LARGE = 800; +const REPS = 15; +const WARMUP = 5; + +function entitySource(i, mode) { + if (mode === 'lombok') { + return `import lombok.Data; +@Data +public class Entity${i} { + private String id; + private String name; + private boolean active; + private Long amount; +} +`; + } + return `public class Entity${i} { + private String id; + private String name; + private boolean active; + private Long amount; +} +`; +} + +function ownerMap(tree, filePath) { + const map = new Map(); + const walk = (node) => { + if (node.type === 'class_declaration') { + const name = node.childForFieldName('name')?.text; + if (name) map.set(node.id, `Class:${filePath}:${name}`); + } + for (const c of node.children) walk(c); + }; + walk(tree.rootNode); + return map; +} + +function prepare(mode, fileCount) { + const files = []; + for (let i = 0; i < fileCount; i++) { + const parser = new Parser(); + parser.setLanguage(Java); + const filePath = `bench/${mode}/Entity${i}.java`; + const tree = parser.parse(entitySource(i, mode)); + files.push({ tree, filePath, owners: ownerMap(tree, filePath) }); + } + return files; +} + +function runAll(files) { + const nodes = []; + for (const f of files) { + const result = synthesizeLombokAccessors(f.tree, f.filePath, f.owners); + for (const n of result.nodes) nodes.push(n.id); + } + return nodes; +} + +function measure(mode, fileCount) { + const files = prepare(mode, fileCount); + const { last, ms } = minSample(() => runAll(files), WARMUP, REPS); + return { + files: fileCount, + ms, + methods: last.length, + fingerprint: fingerprintIds(last), + }; +} + +const report = { + no_lombok_small: measure('bare', SMALL), + no_lombok_large: measure('bare', LARGE), + lombok_small: measure('lombok', SMALL), + lombok_large: measure('lombok', LARGE), +}; +report.scaling_ratio = Number( + (report.lombok_large.ms / report.lombok_small.ms / (LARGE / SMALL)).toFixed(3), +); +report.widening_overhead = Number( + (report.lombok_large.ms / Math.max(report.no_lombok_large.ms, 0.001)).toFixed(3), +); +report.fingerprint = report.lombok_large.fingerprint; + +runMethodCountCheck(report, { + no_lombok_large: 0, + lombok_large: 6400, +}); + +if (!process.argv.includes('--check')) { + console.log(JSON.stringify(report, null, 2)); + process.exit(0); +} + +runBaselineCheck(report, BASELINE_PATH); diff --git a/gitnexus/bench/java-wildcard-route-constants/baselines.json b/gitnexus/bench/java-wildcard-route-constants/baselines.json new file mode 100644 index 000000000..961d9a883 --- /dev/null +++ b/gitnexus/bench/java-wildcard-route-constants/baselines.json @@ -0,0 +1,8 @@ +{ + "_comment": "Baselines for bench/java-wildcard-route-constants/measure.mjs --check (#3110). fingerprint is sha256 over 800 materialized route bindings from 800 constant files and must match the named-import control. The benchmark builds the constant import index once per repo pass, matching ingestion and group wiring.", + "fingerprint": "8114e613e93ce0ef6220b810850888592e0e822fe04bf2eb5d8fc4ec3dbba5ef", + "scaling_budget": 1.6, + "_scaling_note": "(t_large/t_small)/(800/250) while both constant files and wildcard importers scale. Measured about 1.14 with the suffix index; repeated candidate scans are quadratic.", + "absolute_ms_budget": 10, + "_absolute_ms_note": "Wildcard materialization for 800 controllers. Measured about 1.1 ms; the generous ceiling catches gross regressions without treating the near-zero named-import control as a stable ratio denominator." +} diff --git a/gitnexus/bench/java-wildcard-route-constants/measure.mjs b/gitnexus/bench/java-wildcard-route-constants/measure.mjs new file mode 100644 index 000000000..50eda340c --- /dev/null +++ b/gitnexus/bench/java-wildcard-route-constants/measure.mjs @@ -0,0 +1,158 @@ +/** + * Build-free throughput + identity benchmark for Java wildcard-static route constants. + * + * Arms: + * - named: explicit `import static ...ApiPaths.ROUTE_n` control + * - wildcard: `import static ...ApiPaths.*` feature path + * + * Parsing is prepared outside the timer. The measured path mirrors ingestion: + * build the constant-key index once, materialize pending wildcard imports, then + * read the resulting binding. Route folding itself has separate integration + * coverage and an older per-fold index cost shared by both arms. + * + * Usage: + * node --import tsx bench/java-wildcard-route-constants/measure.mjs + * node --import tsx bench/java-wildcard-route-constants/measure.mjs --check + */ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import Parser from 'tree-sitter'; +import Java from 'tree-sitter-java'; +import { + extractJavaModuleConstants, + prepareJavaRouteConstants, +} from '../../src/core/ingestion/route-extractors/java-const-resolver.ts'; +import { + fingerprintIds, + minSampleFresh, + runBaselineCheck, + runCountCheck, + runFingerprintParityCheck, +} from '../lib/route-constant-guard.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const BASELINE_PATH = path.resolve(__dirname, 'baselines.json'); +const SMALL = 250; +const LARGE = 800; +const REPS = 15; +const WARMUP = 5; + +const parser = new Parser(); +parser.setLanguage(Java); + +function constantsSource(i) { + return `package bench.constants; +public final class ApiPaths${i} { + public static final String ROUTE = "/api/routes/${i}"; +} +`; +} + +function controllerSource(i, mode) { + const fqn = `bench.constants.ApiPaths${i}`; + const imported = mode === 'wildcard' ? `import static ${fqn}.*;` : `import static ${fqn}.ROUTE;`; + return `package bench.web; +${imported} +class Controller${i} {} +`; +} + +function cloneConstants(mc) { + return { + literals: new Map(mc.literals), + exprs: new Map(mc.exprs), + imports: new Map(mc.imports), + wildcardImports: mc.wildcardImports ? [...mc.wildcardImports] : undefined, + unfoldableDeclarations: new Set(mc.unfoldableDeclarations ?? []), + }; +} + +function prepare(mode, fileCount) { + const constants = []; + const controllers = []; + for (let i = 0; i < fileCount; i++) { + constants.push({ + key: `bench/constants/ApiPaths${i}.java`, + constants: extractJavaModuleConstants(parser.parse(constantsSource(i))), + }); + controllers.push({ + key: `bench/web/Controller${i}.java`, + route: 'ROUTE', + constants: extractJavaModuleConstants(parser.parse(controllerSource(i, mode))), + }); + } + return { constants, controllers }; +} + +function instantiate(prepared) { + const repo = new Map(); + for (const constant of prepared.constants) { + repo.set(constant.key, cloneConstants(constant.constants)); + } + const controllers = []; + for (const controller of prepared.controllers) { + repo.set(controller.key, cloneConstants(controller.constants)); + controllers.push({ key: controller.key, route: controller.route }); + } + return { repo, controllers }; +} + +function runAll(instance) { + const { repo, controllers } = instance; + prepareJavaRouteConstants(repo); + const bindings = []; + for (const controller of controllers) { + const mc = repo.get(controller.key); + const binding = mc.imports.get(controller.route); + if (binding) { + bindings.push( + `${controller.key}:${controller.route}:${binding.module}:${binding.originalName}`, + ); + } + } + return bindings; +} + +function measure(mode, fileCount) { + const prepared = prepare(mode, fileCount); + // Expansion mutates each importing file's `imports` map. Give every timed + // sample a fresh repo, but build those clones outside the timer. + const { last, ms } = minSampleFresh(() => instantiate(prepared), runAll, WARMUP, REPS); + return { + files: fileCount, + ms, + bindings: last.length, + fingerprint: fingerprintIds(last), + }; +} + +const report = { + named_small: measure('named', SMALL), + named_large: measure('named', LARGE), + wildcard_small: measure('wildcard', SMALL), + wildcard_large: measure('wildcard', LARGE), +}; +report.scaling_ratio = Number( + (report.wildcard_large.ms / report.wildcard_small.ms / (LARGE / SMALL)).toFixed(3), +); +report.overhead_us_per_binding = Number( + ( + ((report.wildcard_large.ms - report.named_large.ms) * 1000) / + report.wildcard_large.bindings + ).toFixed(3), +); +report.absolute_ms = report.wildcard_large.ms; +report.fingerprint = report.wildcard_large.fingerprint; + +runCountCheck(report, 'bindings', { + named_large: LARGE, + wildcard_large: LARGE, +}); +runFingerprintParityCheck(report, 'named_large', 'wildcard_large'); + +if (!process.argv.includes('--check')) { + console.log(JSON.stringify(report, null, 2)); + process.exit(0); +} + +runBaselineCheck(report, BASELINE_PATH); diff --git a/gitnexus/bench/kotlin-import-target/baselines.json b/gitnexus/bench/kotlin-import-target/baselines.json index 6810c0224..9fe244a9a 100644 --- a/gitnexus/bench/kotlin-import-target/baselines.json +++ b/gitnexus/bench/kotlin-import-target/baselines.json @@ -1,14 +1,9 @@ { - "_comment": "Baselines for bench/kotlin-import-target/measure.mjs --check. `fingerprint` is a sha256 over every `fileSet | fromFile | targetRaw -> result` record the correctness corpus resolves, in BOTH file-set iteration orders; it is a CORRECTNESS gate, so drift means Kotlin import resolution started returning a different file set and IMPORTS/CALLS edges moved in every Kotlin repository. Explain it, never re-baseline to make CI green. `cases` and `non_null` are asserted beside it because a shrunken or hollowed corpus produces a perfectly valid fingerprint over a smaller surface — all three are one re-baseline, never separate ones. `scaling_budget`, `depth_budget` and `small_ms_ceiling` are timing gates and carry deliberate headroom for shared CI runners.", - "_provenance": "This fingerprint is the value the PRE-INDEX implementation produces. It was not read off the new code: the same corpus was run against `git show :gitnexus/src/core/ingestion/languages/kotlin/import-target.ts` — the four-tier per-import scan — and against the index that replaced it. Both print ebf1790bf1d42dad483a51f2cbdeb2351e493b9e8236e4eedeef592dd81e2c5c over 20106 cases, 13256 of them non-null. That is what makes the index change a performance change rather than a behaviour change, and it is reproducible: swap the module specifier at the top of measure.mjs for the old file and re-run. The corpus deliberately includes the shapes where the two could have diverged — repeated directory names whose FIRST occurrence is not the parent (`data/src/main/kotlin/com/example/data/Repo.kt` is NOT a child of `data`, because the old scan tested startsWith and then used indexOf), doubly nested same-name directories, an exact match appearing after a suffix match in iteration order, `.kt`/`.kts` stem collisions, backslash paths, repo-root files, wildcard `.*` targets landing on the single-file tier rather than fanning out, and non-Kotlin noise.", - "_gate_controls": "The gate is only worth its baseline if a plausible regression moves it, so each arm was checked against the mutation it exists to catch, with the resolver otherwise untouched. Caught, all with the corpus below: capping suffixByStem key depth at 7 (fingerprint a0e6eb98f9…); skipping the dirChildren suffix loop above depth 8 (d53182ebbc…, non_null 13256 -> 12746); capping a dirChildren bucket at 17 entries (ed3ea85c59…). Also caught, with the RESOLVER untouched and only the corpus edited: dropping the competing file from the exact-beats-earlier-suffix case and emptying the repeated-directory negative case (44df5093ee…). All four passed silently before this corpus carried deep paths, packages above 16 files, queries against suffix keys deeper than 7, and the file set inside the hashed record. Re-check them after any corpus edit — a corpus that stops spanning an axis takes the gate with it.", - "fingerprint": "ebf1790bf1d42dad483a51f2cbdeb2351e493b9e8236e4eedeef592dd81e2c5c", - "cases": 20106, - "non_null": 13256, + "_comment": "Declared-package Kotlin import benchmark. The fingerprint pins external-decoy rejection, path/package disagreement, top-level declarations and overload sets, member and wildcard imports, root packages, malformed package facts, and imported-binding exclusion. Timing budgets guard one parsed-workspace index build per pass.", + "fingerprint": "76ee74bf860c54ef6dc0850f6bec2d0f7267ba3010f552ee89b84dfdbbecd0e2", + "cases": 24, + "non_null": 16, "scaling_budget": 1.6, - "depth_budget": 2.4, - "small_ms_ceiling": 40, - "_scaling_note": "(t_large/t_small)/(1600/400). ~1.0 is linear. OBSERVED BAND: 0.99-1.04 on a 12-core dev box, small arm ~6 ms. Read that band as a floor, not a spec — independent runs on other hardware during review came out 0.954-1.014, 0.965-1.036 and ~0.95-1.08, so a 1.2 reading is noise and should be re-run, not investigated. IMPORTS_PER_FILE is sized so the small arm lands in the ms rather than the ~2 ms a first revision measured, where timer granularity and JIT warm-up, not scaling, set the number; bench/cpp-qualified-ns documents the same artifact. TRIAGE: every timing arm here is a TIMING signal — RE-RUN IT on an idle machine before investigating; runner contention dominates. The fingerprint arm is the opposite: deterministic, a re-run never changes it, and it must never be wished away. FLOOR CHECK: the pre-index implementation — i.e. exactly the regression this gate exists to catch — measures ratio 3.737 on this corpus (2207.8 ms small, 33003.5 ms large, one cold run) against ~1.0 for the index. Independent review runs measured its floor at 3.905-4.297. Treat the absolute times as an order of magnitude only: the floor arm is one cold run because best-of-seven against a quadratic implementation costs minutes, while the index arm is best-of-seven after two warmups.", - "_depth_note": "deep_ms/shallow_ms at a FIXED file count, paths 24 components against 8. scaling_ratio divides the file count out, so it is scale-invariant and structurally cannot see a cost that grows with path depth instead — and both loops this change added are depth loops (one suffixByStem entry per '/' in a stem, one dirChildren pass per component of dir). OBSERVED BAND: 1.44-1.51 over four unloaded runs. It sits above 1.0 legitimately: 3x the depth is 3x the suffix keys per file, so the build genuinely does more work; what the budget of 2.4 forbids is that growing faster than the depth ratio itself.", - "_ceiling_note": "small_ms_ceiling is an ABSOLUTE bound, because scaling_ratio is a ratio and a constant-factor regression that grows both arms equally passes it. Measured during review: a full workspace scan reintroduced on 1-in-16 imports is caught by the ratio (1.814), but at 1-in-32 it passes at 1.490 while running 2.8x slower in absolute terms. 40 ms against an observed 5.9-6.1 ms leaves ~6x of headroom for a loaded shared runner while still catching that shape." + "depth_budget": 1.5, + "small_ms_ceiling": 40 } diff --git a/gitnexus/bench/kotlin-import-target/measure.mjs b/gitnexus/bench/kotlin-import-target/measure.mjs index a11747153..8add8df33 100644 --- a/gitnexus/bench/kotlin-import-target/measure.mjs +++ b/gitnexus/bench/kotlin-import-target/measure.mjs @@ -1,538 +1,239 @@ /** - * Build-free identity + scaling bench for `resolveKotlinImportTarget`, the - * Kotlin import resolver. + * Declared-package correctness and scaling gate for Kotlin import resolution. * - * Before this bench's companion change the resolver walked the ENTIRE - * `allFilePaths` Set on every import. Its four tiers — exact/suffix, - * directory child, package fan-out, progressive prefix strip — each ran - * `for (const raw of allFilePaths)` with a `replace(/\\/g, '/')` and several - * string comparisons per entry, and they are tried in cascade, so one - * unresolved import cost two to four full passes. Resolution was therefore - * O(imports x files). Once a repository reaches tens of thousands of Kotlin - * files that is on the order of 10^10 string operations on one thread: - * `analyze` sits at exactly 1.00 core with a flat heap and emits nothing for - * hours, because every allocation is a short-lived string and nothing - * accumulates to hint at progress. - * - * This is the same shape #1918 fixed for Python and #2788 for C++, and it - * returns the same way: someone adds a tier, reaches for `allFilePaths`, and - * writes a loop. Neither existing gate can catch it here — - * `bench/python-scope/import-target-fingerprint.mjs` drives the Python - * resolver only, and `bench/scope-capture/measure.mjs` fingerprints - * `emitScopeCaptures`, a different function that never calls import - * resolution. Hence this bench, in an always-on CI step. - * - * TWO ARMS, and they fail for opposite reasons: - * - * - `fingerprint` — a sha256 over every `fromFile | targetRaw -> result` - * triple the correctness corpus resolves (an exhaustive branch matrix plus - * a deterministic fuzz). This is a CORRECTNESS gate. Drift means Kotlin - * imports started resolving a DIFFERENT file set, i.e. CALLS/IMPORTS edges - * moved in every Kotlin repository. It is deterministic: a re-run never - * changes it, and it must never be re-baselined to make CI green. This - * value is the one the pre-index implementation produced — see - * `_provenance` in baselines.json. - * - * - `scaling_ratio` — `(t_large/t_small)/(LARGE/SMALL)` over a synthetic - * Kotlin monorepo at two scales, timing the index build TOGETHER with - * resolving every import. ~1.0 is linear; a reintroduced per-import scan - * measures ~4 at this scale gap. This is a TIMING gate: re-run it on an - * idle machine before investigating. - * - * A ratio cannot see a constant factor and a file-count ratio cannot see a - * depth cost, so `--check` also asserts a DEPTH ratio (file count fixed, paths - * ~3x deeper) and an absolute ceiling on the small arm. A full workspace scan - * reintroduced on 1-in-32 imports scores 1.490 — inside the scaling budget — - * while running 2.8x slower; the ceiling is what catches that shape. - * - * One honest limit: at a very small import count the index loses. Building it - * is one workspace pass, so a single import into a 100k-file workspace costs - * ~0.8 s against ~0 for a scan that returns on its first hit. It inverts at - * roughly 15 imports, and in the polyglot case that motivates the worry — - * 100k files, 5% Kotlin, a couple of imports — the index already wins, because - * the build skips non-`.kt` entries as cheaply as the scan did. - * - * Five properties of the corpora are load-bearing and must not be - * "simplified" away: - * - * 1. **The correctness corpus fuzzes each file set in BOTH iteration - * orders.** Every tie-break in this resolver is expressed only through - * Set-iteration order — "first suffix match wins", and the two stem maps - * keeping the FIRST path inserted per key. A single-order corpus scores an - * implementation that keeps the LAST match identically. - * 2. **The correctness corpus contains repeated directory names where the - * first occurrence is not the parent** (`data/src/main/kotlin/com/example/ - * data/Repo.kt`). The pre-index scan tested `startsWith` and then used - * `indexOf`, so it only ever considered the FIRST `/dir/`; that file is - * therefore NOT a child of `data`. The index reproduces it deliberately. - * Without these shapes the fingerprint cannot tell the preserved rule from - * the intuitive one. - * 3. **~40% of the scaling corpus's imports are unresolvable.** The old cost - * was worst when nothing matched, because only then did all four tiers - * run. A corpus where every import hits tier 1 exits after one pass and - * scores a per-import scan far closer to linear. - * 4. **The hashed record includes the FILE SET, not just the query and the - * result.** Otherwise a corpus edit that swaps the workspace under a case - * while leaving its result string alone is invisible: dropping the - * competing file from the "exact beats an earlier suffix" case, or - * emptying the repeated-directory negative case, each leaves `cases`, - * `non_null` and the fingerprint byte-identical and the gate green. - * 5. **Path depth and package size are spanned, not pinned.** Both loops this - * change added are driven by depth — one `suffixByStem` entry per '/' in a - * stem, one `dirChildren` pass per component of `dir` — and the fan-out - * tier returns a bucket whose length is the package size. While the corpus - * capped depth at 8 components and packages at 16 files, three plausible - * follow-up guards (cap suffix depth at 7, skip the `dirChildren` suffix - * loop above depth 8, cap a bucket at 17) all passed `--check` with a - * byte-identical fingerprint — while on a standard Gradle layout the depth - * skip resolved EVERY package import to null and the bucket cap truncated - * fan-out by 58%. Import ARITY, by contrast, was never blind: a tier-4 cap - * at 4 dotted segments already failed the gate, because the branch matrix - * carries 6- and 8-segment cases. + * The production resolver indexes the parsed workspace once per pass. This + * benchmark pins the semantic cases path matching cannot express and verifies + * that work remains linear as files and imports grow together. * * Run: - * node --import tsx bench/kotlin-import-target/measure.mjs # report - * node --import tsx bench/kotlin-import-target/measure.mjs --check # CI gate + * node --import tsx bench/kotlin-import-target/measure.mjs + * node --import tsx bench/kotlin-import-target/measure.mjs --check */ import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { resolveKotlinImportTarget } from '../../src/core/ingestion/languages/kotlin/import-target.ts'; + +import { kotlinScopeResolver } from '../../src/core/ingestion/languages/kotlin/scope-resolver.ts'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const BASELINE_PATH = path.resolve(__dirname, 'baselines.json'); - +const baseline = JSON.parse(fs.readFileSync(path.join(__dirname, 'baselines.json'), 'utf8')); +const CHECK = process.argv.includes('--check'); const SMALL = 400; const LARGE = 1600; -/** Imports per file. Keeps the import count proportional to the file count, so - * a per-import workspace scan shows up as a quadratic ratio rather than being - * amortized away by a fixed import budget. Sized so the SMALL arm measures in - * the tens of ms: at ~2 ms timer granularity and JIT warm-up, not scaling, set - * the ratio — the same artifact bench/cpp-qualified-ns documents. */ -const IMPORTS_PER_FILE = 32; -/** Depth arm: same file count either side, ~3x the path depth on one side. */ -const DEPTH_FILES = 800; -const DEPTH_PAD = 16; +const IMPORTS_PER_FILE = 4; const WARMUP = 2; const REPS = 7; -// --------------------------------------------------------------------------- -// Correctness arm -// --------------------------------------------------------------------------- +function parsedFile(filePath, packageName, exports, imported = []) { + const moduleScope = `module:${filePath}`; + const localDefs = exports.map((name, i) => ({ + nodeId: `Declaration:${filePath}:${i}`, + filePath, + type: i === 0 ? 'Class' : 'Function', + qualifiedName: name, + })); + const bindings = new Map(localDefs.map((def) => [def.qualifiedName, [{ def, origin: 'local' }]])); + for (const name of imported) { + bindings.set(name, [ + { + def: { + nodeId: `Declaration:dependency.kt:${name}`, + filePath: 'dependency.kt', + type: 'Class', + qualifiedName: name, + }, + origin: 'import', + }, + ]); + } + return { + filePath, + moduleScope, + scopes: [ + { + id: moduleScope, + parent: null, + kind: 'Module', + range: { startLine: 1, startCol: 0, endLine: 1, endCol: 1 }, + filePath, + bindings, + ownedDefs: localDefs, + imports: [], + typeBindings: new Map(), + }, + ], + parsedImports: [], + localDefs, + referenceSites: [], + captureSideChannel: { + kind: 'kotlin', + companionScopes: [], + packageFact: packageName === null ? { status: 'unknown' } : { status: 'known', packageName }, + classAnnotations: [], + }, + }; +} -const lines = []; -let nonNull = 0; +function prepare(parsedFiles) { + kotlinScopeResolver.loadResolutionConfig?.(''); + for (const parsed of parsedFiles) kotlinScopeResolver.applyCaptureSideChannel?.(parsed); + return { + parsedFiles, + allFilePaths: new Set(parsedFiles.map((file) => file.filePath)), + }; +} -function resolve(files, targetRaw, fromFile) { - return resolveKotlinImportTarget( - { kind: 'named', localName: 'X', importedName: 'X', targetRaw }, - { fromFile, allFilePaths: new Set(files) }, +function resolve(targetRaw, pass) { + return kotlinScopeResolver.resolveImportTarget( + targetRaw, + pass.parsedFiles[0]?.filePath ?? 'app/Main.kt', + pass.allFilePaths, + undefined, + { + parsedFiles: pass.parsedFiles, + parsedImport: { kind: 'named', localName: 'X', importedName: 'X', targetRaw }, + filesSkipped: 0, + }, ); } -/** Record one case in BOTH file-set iteration orders — see header property 1. */ -function record(files, targetRaw, fromFile = 'App.kt') { - for (const [order, list] of [ - ['fwd', files], - ['rev', [...files].reverse()], - ]) { - const r = resolve(list, targetRaw, fromFile); - if (r !== null) nonNull++; - const rendered = r === null ? 'NULL' : Array.isArray(r) ? `[${r.join(',')}]` : r; - // The FILE SET is part of the hashed record, not just the query and the - // result — see header property 4. Without it a corpus edit that changes - // which workspace a case runs against, while leaving the result string - // alone, is invisible: dropping the competing file from the - // "exact beats an earlier suffix" case, or emptying the repeated-directory - // negative case, both leave `cases`, `non_null` and the fingerprint - // byte-identical. - lines.push(`${order}\t${list.join('|')}\t${fromFile}\t${targetRaw}\t${rendered}`); +function render(answer) { + if (answer === null) return 'null'; + return typeof answer === 'string' ? answer : JSON.stringify(answer); +} + +const correctness = [ + [ + [ + parsedFile('app/Main.kt', 'app', ['main']), + parsedFile('src/main/kotlin/vendor/Assert.kt', 'vendor', ['Assert']), + ], + ['org.junit.Assert', 'vendor.Assert'], + ], + [ + [ + parsedFile('flat/UserSource.kt', 'com.example.model', ['User', 'loadUser']), + parsedFile('other/Order.kt', 'com.example.model', ['Order']), + parsedFile('odd/ToolsFile.kt', 'com.example', ['Tools']), + ], + [ + 'com.example.model.User', + 'com.example.model.loadUser', + 'com.example.model.*', + 'com.example.Tools.format', + 'com.example.Tools.*', + 'com.example.model.Missing', + ], + ], + [ + [ + parsedFile('one.kt', 'dup', ['parse']), + parsedFile('two.kt', 'dup', ['parse']), + parsedFile('Root.kt', '', ['Root']), + parsedFile('Broken.kt', null, ['Broken']), + parsedFile('app.kt', 'app', ['main'], ['External']), + ], + ['dup.parse', 'Root', 'broken.Broken', 'app.External'], + ], +]; + +const records = []; +let nonNull = 0; +for (const [files, targets] of correctness) { + for (const ordered of [files, [...files].reverse()]) { + const pass = prepare(ordered); + for (const target of targets) { + const answer = render(resolve(target, pass)); + if (answer !== 'null') nonNull++; + records.push(`${ordered.map((file) => file.filePath).join(',')}|${target}->${answer}`); + } } } +const fingerprint = crypto.createHash('sha256').update(records.sort().join('\n')).digest('hex'); -// ---- 1. Exhaustive branch matrix ------------------------------------------ - -// Tier 1, exact. -record(['util/User.kt', 'util/Repo.kt'], 'util.User'); -// Tier 1, suffix (import is not workspace-rooted). -record(['src/main/kotlin/util/User.kt'], 'util.User'); -// Exact anywhere beats a suffix found earlier. -record(['deep/util/User.kt', 'util/User.kt'], 'util.User'); -// No exact match: first suffix in iteration order wins. -record(['a/util/User.kt', 'b/util/User.kt'], 'util.User'); -// .kt / .kts sharing a stem. -record(['dup/Thing.kt', 'dup/Thing.kts'], 'dup.Thing'); -// Multi-segment suffix query. -record(['src/main/com/example/User.kt'], 'com.example.User'); -record(['a/b/com/example/User.kt', 'com/example/User.kt'], 'com.example.User'); -// Tier 2: stripped path matches a file (class-or-object holding the member). -record(['util/OneArg.kt'], 'util.OneArg.writeAudit'); -record(['src/main/kotlin/util/OneArg.kt'], 'util.OneArg.writeAudit'); -// Tier 3: package fan-out to every direct child, in order. -record(['models/User.kt', 'models/Repo.kt', 'models/sub/Deep.kt'], 'models.getRepo'); -record(['models/User.kt', 'models/sub/Deep.kt', 'models/Repo.kt'], 'models.getRepo'); -// Fan-out where the package directory is reached by suffix, not at the root. -record(['app/src/main/kotlin/models/User.kt', 'app/src/main/kotlin/models/Repo.kt'], 'models.get'); -// Tier 4: progressive prefix strip, one and several skip levels. -record(['x/y/z/Deep.kt'], 'com.example.z.Deep'); -record(['z/Deep.kt'], 'a.b.c.d.z.Deep'); -record(['q/Deep.kt'], 'a.b.c.d.e.f.q.Deep'); -// Tier 4 reaching the fan-out tier after stripping. -record(['pkg/A.kt', 'pkg/B.kt'], 'com.example.pkg.someFunction'); -// Backslash normalization. -record(['win\\pkg\\A.kt'], 'win.pkg.A'); -record(['win\\pkg\\A.kt', 'win\\pkg\\B.kt'], 'win.pkg.someFunction'); -// Non-Kotlin files never resolve. -record(['pkg/A.java', 'pkg/A.md', 'pkg/A.kt.txt'], 'pkg.A'); -// Kotlin file alongside non-Kotlin noise of the same stem. -record(['pkg/A.java', 'pkg/A.kt'], 'pkg.A'); -// Header property 2: repeated directory name, first occurrence is not the parent. -record(['data/src/main/kotlin/com/example/data/Repo.kt'], 'data.something'); -record(['data/src/main/kotlin/com/example/data/Repo.kt'], 'data.Repo'); -record(['a/c/b/c/File.kt'], 'c.X'); -record(['c/b/c/File.kt'], 'c.X'); -// Doubly nested same-name directory, both below the root. -record(['top/data/mid/data/Repo.kt'], 'data.something'); -// A path starting with the directory name is not its child unless direct. -record(['data/sub/Repo.kt'], 'data.something'); -record(['data/Repo.kt'], 'data.something'); -// Repo-root file has no package directory. -record(['Root.kt'], 'Root'); -record(['Root.kt', 'pkg/Root.kt'], 'Root'); -// Wildcard: `.*` is stripped and lands on the single-file tier, not fan-out. -record(['models/User.kt', 'models/Repo.kt'], 'models.*'); -record(['models/Repo.kt', 'models/User.kt'], 'models.*'); -record(['util/User.kt'], 'util.User.*'); -// Unknown target. -record(['pkg/A.kt'], 'nowhere.Thing'); -// Single-segment target with no directory anywhere. -record(['pkg/A.kt'], 'A'); -// Empty-ish and degenerate targets. -record(['pkg/A.kt'], '*'); -record(['pkg/A.kt'], 'pkg.'); -// fromFile variation must not change the outcome (this resolver ignores it) — -// pinned so a future change that starts consulting it is visible here. -record(['util/User.kt'], 'util.User', 'deep/nested/Caller.kt'); - -// ---- 1b. Depth and package size, the two axes the loops scale on ---------- -// -// Header property 5. The index writes one `suffixByStem` entry per '/' in a -// stem and walks `dir` once per component, so DEPTH is what those two loops -// cost, and `dirChildren` bucket length is what the fan-out tier returns. A -// corpus that pins either as a constant cannot see a guard on it: capping -// suffix-key depth at 7, skipping the `dirChildren` suffix loop above depth 8, -// or capping a bucket at 17 entries all left the fingerprint, `cases` and -// `non_null` byte-identical before these cases existed — while, on a standard -// Gradle layout, the depth skip resolved EVERY package import to null and the -// bucket cap silently truncated fan-out by 58%. -const DEEP = 'core/data/src/main/kotlin/com/example/core/data/repository'; -// 11 components — ordinary for Android/Gradle source, which runs 9-12. -record([`${DEEP}/UserRepository.kt`], 'com.example.core.data.repository.UserRepository'); -record([`${DEEP}/UserRepository.kt`], 'repository.UserRepository'); -record([`${DEEP}/UserRepository.kt`], 'core.data.repository.UserRepository'); -record([`${DEEP}/UserRepository.kt`, `${DEEP}/PostRepository.kt`], 'repository.findAll'); -record([`${DEEP}/UserRepository.kt`, `${DEEP}/PostRepository.kt`], 'core.data.repository.findAll'); -// Deeper still, and with the repeated-name shape at depth. -const DEEPER = 'feature/home/src/main/kotlin/com/example/feature/home/data/local/dao'; -record([`${DEEPER}/UserDao.kt`], 'dao.UserDao'); -record([`${DEEPER}/UserDao.kt`, `${DEEPER}/PostDao.kt`], 'dao.insertAll'); -record([`${DEEPER}/UserDao.kt`], 'home.data.local.dao.UserDao'); -// Suffix keys deeper than 7 components. Depth in the FILE is not enough on its -// own: a cap on how many component-suffixes a stem contributes stays invisible -// unless something QUERIES one of the deep keys, and every Gradle-shaped import -// above is 6 segments or fewer. These reach the top of the stem. -record( - [`${DEEP}/UserRepository.kt`], - 'src.main.kotlin.com.example.core.data.repository.UserRepository', -); -record( - [`${DEEP}/UserRepository.kt`], - 'data.src.main.kotlin.com.example.core.data.repository.UserRepository', -); -record([`${DEEPER}/UserDao.kt`], 'src.main.kotlin.com.example.feature.home.data.local.dao.UserDao'); -record( - [`${DEEPER}/UserDao.kt`], - 'home.src.main.kotlin.com.example.feature.home.data.local.dao.UserDao', -); -record( - [`${DEEP}/UserRepository.kt`, `${DEEP}/PostRepository.kt`], - 'src.main.kotlin.com.example.core.data.repository.findAll', -); - -// A package larger than any plausible bucket cap. 40 files in one package is -// ordinary; a silent sibling cap is exactly what #2732 shipped on the JVM side. -const BIG_PACKAGE = Array.from({ length: 40 }, (_, i) => `${DEEP}/Item${i}.kt`); -record(BIG_PACKAGE, 'repository.someTopLevelFun'); -record(BIG_PACKAGE, 'com.example.core.data.repository.someTopLevelFun'); -record([...BIG_PACKAGE, `${DEEP}/sub/Nested.kt`], 'repository.someTopLevelFun'); - -// ---- 2. Deterministic fuzz ------------------------------------------------- - -/** xorshift32 — seeded, so the corpus is identical on every machine. */ -let seed = 0x9e3779b9; -function rnd() { - seed ^= seed << 13; - seed ^= seed >>> 17; - seed ^= seed << 5; - seed >>>= 0; - return seed / 0x100000000; -} -function pick(arr) { - return arr[Math.floor(rnd() * arr.length)]; -} - -const DIRS = [ - '', - 'app', - 'core', - 'data', - 'feature/home', - 'lib/data', - 'src/main/kotlin', - 'src/main/kotlin/com/example', - 'module/src/main/kotlin/com/example/data', - 'data/src/main/kotlin/com/example/data', - 'top/data/mid/data', - 'win\\pkg', - // Depth beyond the Gradle norm, so the fuzz spans the axis too rather than - // leaving it to the hand-written cases above. - 'core/data/src/main/kotlin/com/example/core/data/repository', - 'feature/home/src/main/kotlin/com/example/feature/home/data/local/dao', - 'a/b/c/d/e/f/g/h/i/j/k/l', -]; -// Segment alphabet overlaps the DIRS entries on purpose: a random dotted target -// only exercises a deep suffix key if its segments can actually align with a -// deep path. -const SEGS = [ - 'User', - 'Repo', - 'Util', - 'Service', - 'Model', - 'data', - 'core', - 'api', - 'store', - 'sub', - 'src', - 'main', - 'kotlin', - 'com', - 'example', - 'repository', - 'dao', -]; -const EXTS = ['.kt', '.kt', '.kt', '.kts', '.java', '.md']; - -function randPath() { - const dir = pick(DIRS); - const base = pick(SEGS); - const file = `${base}${pick(EXTS)}`; - if (dir === '') return file; - return dir.includes('\\') ? `${dir}\\${file}` : `${dir}/${file}`; -} -function randDotted() { - // Up to 9 segments, not 4: import arity is the one axis the branch matrix - // already spanned, but the fuzz should cover it too now that the corpus - // carries paths deep enough for a long target to align with one. - const n = 1 + Math.floor(rnd() * 9); - const parts = []; - for (let i = 0; i < n; i++) parts.push(pick(SEGS)); - return rnd() < 0.12 ? `${parts.join('.')}.*` : parts.join('.'); -} - -// File counts run to 45, not 16: a package that never exceeds 16 direct -// children cannot distinguish an uncapped `dirChildren` bucket from one capped -// at 17 (header property 5). -for (let repo = 0; repo < 400; repo++) { - const fileCount = 3 + Math.floor(rnd() * 43); - const files = []; - for (let i = 0; i < fileCount; i++) files.push(randPath()); - const fromFile = randPath(); - for (let imp = 0; imp < 25; imp++) record(files, randDotted(), fromFile); -} - -const correctnessFingerprint = crypto - .createHash('sha256') - .update([...lines].sort().join('\n')) - .digest('hex'); - -// --------------------------------------------------------------------------- -// Scaling arm -// --------------------------------------------------------------------------- - -/** A synthetic Kotlin monorepo: Gradle-module roots over a shared package - * namespace, at the path depth real Kotlin source has (the index stores one - * suffix entry per '/' in a stem and walks `dir` once per component, so depth - * is a cost driver and a flat corpus would understate the build). - * - * `padDepth` inserts filler segments so the depth arm below can hold the file - * count fixed and vary only depth — the scaling ratio is scale-invariant in - * FILE COUNT and would otherwise never see a depth-driven cost regression. */ function buildCorpus(fileCount, padDepth = 0) { - const pad = Array.from({ length: padDepth }, (_, d) => `p${d}`).join('/'); + const pad = Array.from({ length: padDepth }, (_, i) => `deep${i}`).join('/'); const files = []; + const packages = Math.max(1, Math.floor(fileCount / 8)); for (let i = 0; i < fileCount; i++) { - const mod = i % 16; - const root = pad === '' ? `lib${mod}` : `lib${mod}/${pad}`; - files.push(`${root}/src/main/kotlin/com/example/mod${mod}/Class${i}.kt`); + const pkg = i % packages; + const prefix = pad === '' ? `mod${pkg}` : `mod${pkg}/${pad}`; + files.push( + parsedFile( + `${prefix}/src/main/kotlin/com/example/pkg${pkg}/Source${i}.kt`, + `com.example.pkg${pkg}`, + [`File${i}`, `topLevel${i}`], + ), + ); } return files; } -/** Import targets for the corpus, ~40% of them unresolvable — see header - * property 3: only a miss drives all four tiers, which is where the - * per-import scan was worst. */ function buildImports(fileCount) { - const imports = []; - for (let i = 0; i < fileCount * IMPORTS_PER_FILE; i++) { - const kind = i % 5; - const mod = i % 16; - if (kind === 0) - imports.push(`com.example.mod${mod}.Class${i % fileCount}`); // tier 1 hit - else if (kind === 1) - imports.push(`com.example.mod${mod}.someFunction`); // fan-out - else if (kind === 2) - imports.push(`mod${mod}.Class${i % fileCount}`); // suffix - else imports.push(`org.absent.pkg${mod}.Missing${i}`); // full cascade, no hit - } - return imports; -} - -function fastest(values) { - return Math.min(...values); -} - -/** - * Time one full pass: the index build PLUS resolving every import. The build is - * the work the per-import scan was traded for, so hiding it would let an index - * that is itself quadratic pass. Each pass gets its own Set object, because the - * index is memoized on Set identity and a shared Set would build once and make - * every later pass free. The Sets are constructed OUTSIDE the timer so their - * own O(files) cost never lands in the measurement. - */ -function timeResolution(files, imports) { - const sets = []; - for (let i = 0; i < WARMUP + REPS; i++) sets.push(new Set(files)); - const fromFile = files[0]; - - for (let w = 0; w < WARMUP; w++) { - for (const t of imports) { - resolveKotlinImportTarget( - { kind: 'named', localName: 'X', importedName: 'X', targetRaw: t }, - { fromFile, allFilePaths: sets[w] }, - ); + const packages = Math.max(1, Math.floor(fileCount / 8)); + return Array.from({ length: fileCount * IMPORTS_PER_FILE }, (_, i) => { + const file = i % fileCount; + const pkg = file % packages; + switch (i % 4) { + case 0: + return `com.example.pkg${pkg}.File${file}`; + case 1: + return `com.example.pkg${pkg}.topLevel${file}`; + case 2: + return `com.example.pkg${pkg}.*`; + default: + return `org.external.pkg${pkg}.Missing${i}`; } - } - const samples = []; - for (let r = 0; r < REPS; r++) { - const set = sets[WARMUP + r]; - const t0 = performance.now(); - for (const t of imports) { - resolveKotlinImportTarget( - { kind: 'named', localName: 'X', importedName: 'X', targetRaw: t }, - { fromFile, allFilePaths: set }, - ); - } - samples.push(performance.now() - t0); - } - return fastest(samples); + }); } -const scales = {}; -for (const [name, fileCount] of [ - ['small', SMALL], - ['large', LARGE], -]) { - const files = buildCorpus(fileCount); +function timeResolution(fileCount, padDepth = 0) { + const workspaces = Array.from({ length: WARMUP + REPS }, () => buildCorpus(fileCount, padDepth)); const imports = buildImports(fileCount); - scales[name] = { - files: fileCount, - imports: imports.length, - ms: Number(timeResolution(files, imports).toFixed(3)), - }; + const samples = []; + for (let run = 0; run < workspaces.length; run++) { + const pass = prepare(workspaces[run]); + const start = performance.now(); + let sink = 0; + for (const target of imports) if (resolve(target, pass) !== null) sink++; + const elapsed = performance.now() - start; + if (sink === 0) throw new Error('benchmark workload resolved nothing'); + if (run >= WARMUP) samples.push(elapsed); + } + return Math.min(...samples); } -const scalingRatio = scales.large.ms / scales.small.ms / (LARGE / SMALL); - -// Depth arm: file count fixed, depth roughly tripled. `scaling_ratio` divides -// out the file count, so it is scale-INVARIANT and structurally cannot see a -// cost that grows with path depth instead — and both loops this PR added are -// depth loops. Same corpus size, same imports, only the paths get longer. -const depthFiles = buildCorpus(DEPTH_FILES, 0); -const depthFilesPadded = buildCorpus(DEPTH_FILES, DEPTH_PAD); -const depthImports = buildImports(DEPTH_FILES); -const shallowMs = timeResolution(depthFiles, depthImports); -const deepMs = timeResolution(depthFilesPadded, depthImports); -const depthRatio = deepMs / shallowMs; - +const smallMs = timeResolution(SMALL); +const largeMs = timeResolution(LARGE); +const deepMs = timeResolution(SMALL, 16); const report = { - small: scales.small, - large: scales.large, - scaling_ratio: Number(scalingRatio.toFixed(3)), - depth: { - files: DEPTH_FILES, - shallow_components: 8, - deep_components: 8 + DEPTH_PAD, - shallow_ms: Number(shallowMs.toFixed(3)), - deep_ms: Number(deepMs.toFixed(3)), - }, - depth_ratio: Number(depthRatio.toFixed(3)), - cases: lines.length, + fingerprint, + cases: records.length, non_null: nonNull, - fingerprint: correctnessFingerprint, + small: { files: SMALL, imports: SMALL * IMPORTS_PER_FILE, ms: Number(smallMs.toFixed(3)) }, + large: { files: LARGE, imports: LARGE * IMPORTS_PER_FILE, ms: Number(largeMs.toFixed(3)) }, + scaling_ratio: Number((largeMs / smallMs / (LARGE / SMALL)).toFixed(3)), + depth_ratio: Number((deepMs / smallMs).toFixed(3)), }; -if (!process.argv.includes('--check')) { - console.log(JSON.stringify(report, null, 2)); - process.exit(0); -} +console.log(JSON.stringify(report, null, 2)); +if (!CHECK) process.exit(0); -const baseline = JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf-8')); const failures = []; -if (report.fingerprint !== baseline.fingerprint) { - failures.push( - `fingerprint drift: ${report.fingerprint} != ${baseline.fingerprint} — Kotlin import ` + - `resolution returned a DIFFERENT file set. That is a behaviour change, not a perf one: ` + - `IMPORTS/CALLS edges move in every Kotlin repository. Explain it, never re-baseline to ` + - `make CI green.`, - ); -} -for (const field of ['cases', 'non_null']) { - if (report[field] !== baseline[field]) { - failures.push( - `${field} ${report[field]} != ${baseline[field]} — the corpus itself changed, so the ` + - `fingerprint above is computed over a different surface and proves nothing about the ` + - `resolver. Re-baseline every corpus field together, deliberately.`, - ); - } +for (const key of ['fingerprint', 'cases', 'non_null']) { + if (report[key] !== baseline[key]) failures.push(`${key}: ${report[key]} != ${baseline[key]}`); } if (report.scaling_ratio > baseline.scaling_budget) { - failures.push( - `scaling ${report.scaling_ratio} > budget ${baseline.scaling_budget} — per-import cost grows ` + - `with workspace size again, i.e. a tier went back to walking allFilePaths. Timing arm: ` + - `re-run on an idle machine before investigating (see _scaling_note in baselines.json); the ` + - `fingerprint arm is deterministic and never warrants a re-run.`, - ); + failures.push(`scaling_ratio ${report.scaling_ratio} > ${baseline.scaling_budget}`); } if (report.depth_ratio > baseline.depth_budget) { - failures.push( - `depth ratio ${report.depth_ratio} > budget ${baseline.depth_budget} — cost now grows with ` + - `PATH DEPTH at a fixed file count. scaling_ratio divides the file count out and cannot ` + - `see this. Timing arm: re-run on an idle machine first.`, - ); + failures.push(`depth_ratio ${report.depth_ratio} > ${baseline.depth_budget}`); } if (report.small.ms > baseline.small_ms_ceiling) { - failures.push( - `small arm ${report.small.ms} ms > ceiling ${baseline.small_ms_ceiling} ms — scaling_ratio is ` + - `a RATIO, so a constant-factor regression that grows both arms equally passes it (a full ` + - `scan reintroduced on 1-in-32 imports measured 1.490, inside the budget, while running ` + - `2.8x slower). This ceiling is what catches that. Timing arm: re-run on an idle machine.`, - ); + failures.push(`small.ms ${report.small.ms} > ${baseline.small_ms_ceiling}`); } -console.log(JSON.stringify(report, null, 2)); if (failures.length > 0) { console.error(`[kotlin-import-target --check] FAIL\n - ${failures.join('\n - ')}`); process.exit(1); diff --git a/gitnexus/bench/kotlin-jvm-accessors/baselines.json b/gitnexus/bench/kotlin-jvm-accessors/baselines.json new file mode 100644 index 000000000..6ef877719 --- /dev/null +++ b/gitnexus/bench/kotlin-jvm-accessors/baselines.json @@ -0,0 +1,8 @@ +{ + "_comment": "Baselines for bench/kotlin-jvm-accessors/measure.mjs --check (#2885). fingerprint is sha256 over synthetic Method node ids on the data_large corpus (800 data classes × 4 vars × 2 accessors = 6400 methods). no_props arm uses @JvmField so kotlinc and the synthesizer emit 0 accessor methods. Budgets are timing gates with CI headroom.", + "fingerprint": "18e4f295a437a747c486699e8ec5d310d9bde54437d9a96356a1b1bf8442b0ef", + "scaling_budget": 1.6, + "_scaling_note": "(t_large/t_small)/(800/250) on the data-class arm. Measured ~1.02.", + "widening_overhead_budget": 2.5, + "_widening_overhead_note": "data_large_ms / no_props_large_ms. The @JvmField control preserves four property declarations without accessors; budget guards against a pathological synthesis-arm regression." +} diff --git a/gitnexus/bench/kotlin-jvm-accessors/measure.mjs b/gitnexus/bench/kotlin-jvm-accessors/measure.mjs new file mode 100644 index 000000000..0edbfb1e4 --- /dev/null +++ b/gitnexus/bench/kotlin-jvm-accessors/measure.mjs @@ -0,0 +1,121 @@ +/** + * Build-free throughput + identity bench for Kotlin JVM accessor synthesis. + * + * Arms: + * - no_props: @JvmField properties with no JVM accessors (control) + * - data_class: data class constructor properties (feature path) + * + * Usage: + * node --import tsx bench/kotlin-jvm-accessors/measure.mjs + * node --import tsx bench/kotlin-jvm-accessors/measure.mjs --check + */ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import Parser from 'tree-sitter'; +import { SupportedLanguages } from 'gitnexus-shared'; +import { getLanguageGrammar } from '../../src/core/tree-sitter/parser-loader.ts'; +import { synthesizeLombokAccessors } from '../../src/core/ingestion/languages/kotlin/lombok-synthesizer.ts'; +import { + fingerprintIds, + minSample, + runBaselineCheck, + runMethodCountCheck, +} from '../lib/identity-guard.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const BASELINE_PATH = path.resolve(__dirname, 'baselines.json'); + +const SMALL = 250; +const LARGE = 800; +const REPS = 15; +const WARMUP = 5; + +function entitySource(i, mode) { + if (mode === 'data') { + return `data class Entity${i}(var id: String, var name: String, var active: Boolean, var amount: Long) +`; + } + // @JvmField suppresses accessors in kotlinc and in the synthesizer while + // retaining the same four property declarations as the feature arm. + return `class Entity${i} { + @JvmField var id: String = "" + @JvmField var name: String = "" + @JvmField var active: Boolean = false + @JvmField var amount: Long = 0 +} +`; +} + +function ownerMap(tree, filePath) { + const map = new Map(); + const walk = (node) => { + if (node.type === 'class_declaration' || node.type === 'object_declaration') { + const name = + node.childForFieldName('name')?.text ?? + node.namedChildren.find((c) => c.type === 'type_identifier')?.text; + if (name) map.set(node.id, `Class:${filePath}:${name}`); + } + for (const c of node.children) walk(c); + }; + walk(tree.rootNode); + return map; +} + +function prepare(mode, fileCount) { + const files = []; + const lang = getLanguageGrammar(SupportedLanguages.Kotlin); + for (let i = 0; i < fileCount; i++) { + const parser = new Parser(); + parser.setLanguage(lang); + const filePath = `bench/${mode}/Entity${i}.kt`; + const tree = parser.parse(entitySource(i, mode)); + files.push({ tree, filePath, owners: ownerMap(tree, filePath), parser }); + } + return files; +} + +function runAll(files) { + const nodes = []; + for (const f of files) { + const result = synthesizeLombokAccessors(f.tree, f.filePath, f.owners); + for (const n of result.nodes) nodes.push(n.id); + } + return nodes; +} + +function measure(mode, fileCount) { + const files = prepare(mode, fileCount); + const { last, ms } = minSample(() => runAll(files), WARMUP, REPS); + return { + files: fileCount, + ms, + methods: last.length, + fingerprint: fingerprintIds(last), + }; +} + +const report = { + no_props_small: measure('hand', SMALL), + no_props_large: measure('hand', LARGE), + data_small: measure('data', SMALL), + data_large: measure('data', LARGE), +}; +report.scaling_ratio = Number( + (report.data_large.ms / report.data_small.ms / (LARGE / SMALL)).toFixed(3), +); +report.widening_overhead = Number( + (report.data_large.ms / Math.max(report.no_props_large.ms, 0.001)).toFixed(3), +); +report.fingerprint = report.data_large.fingerprint; + +runMethodCountCheck(report, { + no_props_large: 0, + data_large: 6400, +}); + +if (!process.argv.includes('--check')) { + console.log(JSON.stringify(report, null, 2)); + process.exit(0); +} + +runBaselineCheck(report, BASELINE_PATH); diff --git a/gitnexus/bench/kotlin-star-route-constants/baselines.json b/gitnexus/bench/kotlin-star-route-constants/baselines.json new file mode 100644 index 000000000..a68d3ba9f --- /dev/null +++ b/gitnexus/bench/kotlin-star-route-constants/baselines.json @@ -0,0 +1,10 @@ +{ + "_comment": "Baselines for bench/kotlin-star-route-constants/measure.mjs --check (#3110). fingerprint is sha256 over 800 folded route facts from 800 constant files and must match the explicit-import control. The feature arm resolves package-star names through one prepared KotlinConstantIndex.", + "fingerprint": "881101236c511d73d3894d3c9bd2e4a166e3437329149dd99fd16c256435482e", + "scaling_budget": 1.6, + "_scaling_note": "(t_large/t_small)/(800/250) while both constant files and importing controllers scale. Measured about 1.07-1.14.", + "widening_overhead_budget": 2.5, + "_widening_overhead_note": "star_large_ms / named_large_ms. Measured below 1.0; budget guards against a pathological star-lookup regression.", + "absolute_ms_budget": 5, + "_absolute_ms_note": "Package-star folding for 800 controllers. Measured below 0.6 ms; budget includes substantial CI headroom." +} diff --git a/gitnexus/bench/kotlin-star-route-constants/measure.mjs b/gitnexus/bench/kotlin-star-route-constants/measure.mjs new file mode 100644 index 000000000..299accdb8 --- /dev/null +++ b/gitnexus/bench/kotlin-star-route-constants/measure.mjs @@ -0,0 +1,156 @@ +/** + * Build-free throughput + identity benchmark for Kotlin package-star route constants. + * + * Arms: + * - named: explicit `import bench.constants.ROUTE_n` control + * - star: `import bench.constants.*` feature path + * + * Parsing is prepared outside the timer. The measured path mirrors the Kotlin + * group plugin: overlay one importing controller on the prepared constant + * index, then fold its route. + * + * Usage: + * node --import tsx bench/kotlin-star-route-constants/measure.mjs + * node --import tsx bench/kotlin-star-route-constants/measure.mjs --check + */ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import Parser from 'tree-sitter'; +import { requireVendoredGrammar } from '../../src/core/tree-sitter/vendored-grammars.ts'; +import { + buildKotlinConstantIndex, + extractKotlinModuleConstants, + foldKotlinOperands, + overlayKotlinConstantIndex, +} from '../../src/core/ingestion/route-extractors/kotlin-const-resolver.ts'; +import { + fingerprintIds, + minSampleFresh, + runBaselineCheck, + runCountCheck, + runFingerprintParityCheck, +} from '../lib/route-constant-guard.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const BASELINE_PATH = path.resolve(__dirname, 'baselines.json'); +const SMALL = 250; +const LARGE = 800; +const REPS = 15; +const WARMUP = 5; + +const parser = new Parser(); +parser.setLanguage(requireVendoredGrammar('tree-sitter-kotlin')); + +function constantsSource(i) { + return `package bench.constants +const val ROUTE_${i} = "/api/routes/${i}" +`; +} + +function controllerSource(i, mode) { + const route = `ROUTE_${i}`; + const imported = mode === 'star' ? 'import bench.constants.*' : `import bench.constants.${route}`; + return `package bench.web +${imported} +class Controller${i} +`; +} + +function cloneConstants(mc) { + return { + literals: new Map(mc.literals), + exprs: new Map(mc.exprs), + imports: new Map(mc.imports), + wildcardImports: mc.wildcardImports ? [...mc.wildcardImports] : undefined, + packageName: mc.packageName, + unfoldableDeclarations: new Set(mc.unfoldableDeclarations), + topLevelDeclarations: new Set(mc.topLevelDeclarations), + }; +} + +function prepare(mode, fileCount) { + const constants = []; + const controllers = []; + for (let i = 0; i < fileCount; i++) { + constants.push({ + key: `bench/constants/ApiPaths${i}.kt`, + constants: extractKotlinModuleConstants(parser.parse(constantsSource(i))), + }); + controllers.push({ + key: `bench/web/Controller${i}.kt`, + route: `ROUTE_${i}`, + constants: extractKotlinModuleConstants(parser.parse(controllerSource(i, mode))), + }); + } + return { constants, controllers }; +} + +function instantiate(prepared) { + const baseRepo = new Map(); + for (const constant of prepared.constants) { + baseRepo.set(constant.key, cloneConstants(constant.constants)); + } + const controllers = prepared.controllers.map((controller) => ({ + key: controller.key, + route: controller.route, + constants: cloneConstants(controller.constants), + })); + return { baseRepo, controllers }; +} + +function runAll(instance) { + const { baseRepo, controllers } = instance; + const baseIndex = buildKotlinConstantIndex(baseRepo); + const routes = []; + for (const controller of controllers) { + const index = overlayKotlinConstantIndex(baseIndex, controller.key, controller.constants); + const route = foldKotlinOperands( + controller.key, + [{ kind: 'ref', name: controller.route }], + index.repo, + [], + index, + ); + if (route !== null) routes.push(`${controller.key}:${route}`); + } + return routes; +} + +function measure(mode, fileCount) { + const prepared = prepare(mode, fileCount); + const { last, ms } = minSampleFresh(() => instantiate(prepared), runAll, WARMUP, REPS); + return { + files: fileCount, + ms, + routes: last.length, + fingerprint: fingerprintIds(last), + }; +} + +const report = { + named_small: measure('named', SMALL), + named_large: measure('named', LARGE), + star_small: measure('star', SMALL), + star_large: measure('star', LARGE), +}; +report.scaling_ratio = Number( + (report.star_large.ms / report.star_small.ms / (LARGE / SMALL)).toFixed(3), +); +report.widening_overhead = Number( + (report.star_large.ms / Math.max(report.named_large.ms, 0.001)).toFixed(3), +); +report.absolute_ms = report.star_large.ms; +report.fingerprint = report.star_large.fingerprint; + +runCountCheck(report, 'routes', { + named_large: LARGE, + star_large: LARGE, +}); +runFingerprintParityCheck(report, 'named_large', 'star_large'); + +if (!process.argv.includes('--check')) { + console.log(JSON.stringify(report, null, 2)); + process.exit(0); +} + +runBaselineCheck(report, BASELINE_PATH); diff --git a/gitnexus/bench/lib/identity-guard.mjs b/gitnexus/bench/lib/identity-guard.mjs new file mode 100644 index 000000000..b73a1a241 --- /dev/null +++ b/gitnexus/bench/lib/identity-guard.mjs @@ -0,0 +1,62 @@ +/** + * Shared fingerprint + --check for JVM accessor synthesis benches. + */ +import fs from 'node:fs'; +import crypto from 'node:crypto'; + +export function fingerprintIds(ids) { + return crypto + .createHash('sha256') + .update([...ids].sort().join('\n')) + .digest('hex'); +} + +export function minSample(run, warmup, reps) { + for (let w = 0; w < warmup; w++) run(); + const samples = []; + let last; + for (let r = 0; r < reps; r++) { + const t0 = performance.now(); + last = run(); + samples.push(performance.now() - t0); + } + return { last, ms: Math.min(...samples) }; +} + +export function runMethodCountCheck(report, expectedCounts) { + const errors = []; + for (const [arm, expected] of Object.entries(expectedCounts)) { + const actual = report[arm]?.methods; + if (actual !== expected) { + errors.push(`${arm}.methods ${String(actual)} != ${expected}`); + } + } + if (errors.length) { + console.error(JSON.stringify({ report, errors }, null, 2)); + process.exit(1); + } +} + +export function runBaselineCheck(report, baselinePath) { + const baseline = JSON.parse(fs.readFileSync(baselinePath, 'utf-8')); + const errors = []; + if (report.fingerprint !== baseline.fingerprint) { + errors.push(`fingerprint drift: ${report.fingerprint} != ${baseline.fingerprint}`); + } + if (report.scaling_ratio > baseline.scaling_budget) { + errors.push(`scaling_ratio ${report.scaling_ratio} > ${baseline.scaling_budget}`); + } + if ( + baseline.widening_overhead_budget !== undefined && + report.widening_overhead > baseline.widening_overhead_budget + ) { + errors.push( + `widening_overhead ${report.widening_overhead} > ${baseline.widening_overhead_budget}`, + ); + } + if (errors.length) { + console.error(JSON.stringify({ report, errors }, null, 2)); + process.exit(1); + } + console.log(JSON.stringify({ ok: true, report }, null, 2)); +} diff --git a/gitnexus/bench/lib/route-constant-guard.mjs b/gitnexus/bench/lib/route-constant-guard.mjs new file mode 100644 index 000000000..cae8b95fc --- /dev/null +++ b/gitnexus/bench/lib/route-constant-guard.mjs @@ -0,0 +1,77 @@ +/** Shared fingerprint + --check helpers for route-constant benchmarks. */ +import fs from 'node:fs'; +import crypto from 'node:crypto'; + +export function fingerprintIds(ids) { + return crypto + .createHash('sha256') + .update([...ids].sort().join('\n')) + .digest('hex'); +} + +/** Min sample for mutating benchmarks that need fresh state per repetition. */ +export function minSampleFresh(create, run, warmup, reps) { + for (let w = 0; w < warmup; w++) run(create()); + const samples = []; + let last; + for (let r = 0; r < reps; r++) { + const state = create(); + const t0 = performance.now(); + last = run(state); + samples.push(performance.now() - t0); + } + return { last, ms: Math.min(...samples) }; +} + +export function runCountCheck(report, field, expectedCounts) { + const errors = []; + for (const [arm, expected] of Object.entries(expectedCounts)) { + const actual = report[arm]?.[field]; + if (actual !== expected) { + errors.push(`${arm}.${field} ${String(actual)} != ${expected}`); + } + } + failIfNeeded(report, errors); +} + +export function runFingerprintParityCheck(report, leftArm, rightArm) { + const left = report[leftArm]?.fingerprint; + const right = report[rightArm]?.fingerprint; + failIfNeeded( + report, + left === right ? [] : [`${leftArm}.fingerprint ${left} != ${rightArm}.fingerprint ${right}`], + ); +} + +export function runBaselineCheck(report, baselinePath) { + const baseline = JSON.parse(fs.readFileSync(baselinePath, 'utf-8')); + const errors = []; + if (report.fingerprint !== baseline.fingerprint) { + errors.push(`fingerprint drift: ${report.fingerprint} != ${baseline.fingerprint}`); + } + if (report.scaling_ratio > baseline.scaling_budget) { + errors.push(`scaling_ratio ${report.scaling_ratio} > ${baseline.scaling_budget}`); + } + if ( + baseline.absolute_ms_budget !== undefined && + report.absolute_ms > baseline.absolute_ms_budget + ) { + errors.push(`absolute_ms ${report.absolute_ms} > ${baseline.absolute_ms_budget}`); + } + if ( + baseline.widening_overhead_budget !== undefined && + report.widening_overhead > baseline.widening_overhead_budget + ) { + errors.push( + `widening_overhead ${report.widening_overhead} > ${baseline.widening_overhead_budget}`, + ); + } + failIfNeeded(report, errors); + console.log(JSON.stringify({ ok: true, report }, null, 2)); +} + +function failIfNeeded(report, errors) { + if (errors.length === 0) return; + console.error(JSON.stringify({ report, errors }, null, 2)); + process.exit(1); +} diff --git a/gitnexus/bench/python-scope/baseline-fingerprint.txt b/gitnexus/bench/python-scope/baseline-fingerprint.txt index 49a146016..aff56e0d5 100644 --- a/gitnexus/bench/python-scope/baseline-fingerprint.txt +++ b/gitnexus/bench/python-scope/baseline-fingerprint.txt @@ -1 +1 @@ -a0da3e7c00f603e4bdad91a376b3fc181577a73c2ca1719ab7449d3463c671e0 +2600a1f6f8a042eb4f520a7870c34d9ca292765824537c3bc861b40dac8769a8 diff --git a/gitnexus/bench/receiver-resolution/baseline.json b/gitnexus/bench/receiver-resolution/baseline.json index 7a427598f..cf8b8abeb 100644 --- a/gitnexus/bench/receiver-resolution/baseline.json +++ b/gitnexus/bench/receiver-resolution/baseline.json @@ -200,11 +200,11 @@ }, "countArm": { "callDrops": 102, - "totalDropsAllKinds": 140, + "totalDropsAllKinds": 148, "bySiteKind": { "call": 102, "read": 27, - "write": 11 + "write": 19 }, "callDropsByExtension": { ".java": 49, diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index 5b8de09a9..7dcd85c15 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -1,27 +1,28 @@ { "_comment": "Per-language baselines for bench/scope-capture/measure.mjs --check. fingerprint = order-independent sha256 over the lang-resolution/-* fixture corpus + a 20-entity synthetic source (correctness gate; re-baseline intentionally on a legitimate capture change). scaling_budget = max allowed (t800/t250)/(800/250); ~1.0 is linear, ~3.2 is quadratic. The synthetic source is now HERITAGE-BEARING for every language (each Entity extends/implements/embeds/uses-trait/conforms-to a shared base) so the #1951 @reference.inherits synth is gated at scale, not just the base capture loop. All languages thread the tree-sitter captured node instead of re-deriving it with findNodeAtRange(tree.rootNode,...) per match, so all are linear (go #1915, python #1918, ruby/php/rust/csharp #1951, java #1956).", "go": { - "fingerprint": "e386598526e502d131e52a17d219635b3a4196d94f1ebdd25922a2582c985d18", + "fingerprint": "9c554a9d698a2b79fb419852daadca87b8aae88180cceabf9c8d82f3e3300f2e", "scaling_budget": 1.5, "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 3d4e32e7490c830516126e28931827949baa3594cb521f7a3d8dcfed95b6018a -> 57b3c55135af8d2af33b9a7c4bf89796a7bee5b5822b402a2dea91af7232cf4a; scaling 1.058 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: provider-owned callable assignment/copy/formal/argument/invoke facts with invocation/constructor-result suppression. Prior 09ecd94911b830f52fa8807560abcbd79f163d02a2072870c1a59297e9a326e1 -> 3d4e32e7490c830516126e28931827949baa3594cb521f7a3d8dcfed95b6018a; scaling 1.039 < 1.5.", "_rebaselined": "#1976: F33 generic composite literal constructor inference adds generic_type captures in composite_literal patterns; fingerprint drift expected.", - "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior 57b3c55135af8d2af33b9a7c4bf89796a7bee5b5822b402a2dea91af7232cf4a -> 5d6c59c2f2c0dd937c53bf5d736e0f8376b2899a381e488a33aec23524823efb.", + "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 57b3c55135af8d2af33b9a7c4bf89796a7bee5b5822b402a2dea91af7232cf4a -> 5d6c59c2f2c0dd937c53bf5d736e0f8376b2899a381e488a33aec23524823efb.", "_rebaselined_2766_go_pointer_receiver_fixture": "#2766: added test/fixtures/lang-resolution/go-pointer-receiver-field-chain/ (2 Go files) as the committed regression fixture for pointer-receiver base resolution. Go fixture_count 100 -> 102. Prior 5d6c59c2f2c0dd937c53bf5d736e0f8376b2899a381e488a33aec23524823efb -> 8cba537ff211fab3bac5fb4456cd1ffba14d6a2db75c40acae28ab8bf29f3d2e. FIXTURE-CORPUS GROWTH, NOT A CAPTURE CHANGE: the accompanying fix is a resolution-time lookup fallback (stripTypePreservingDecoration) and cannot move capture output; go was the ONLY language whose fingerprint drifted, and every other language matched its baseline on the same run.", - "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 8cba537ff211fab3bac5fb4456cd1ffba14d6a2db75c40acae28ab8bf29f3d2e -> 8162272bb897b0b89472c406321cf8d88a5ae4ea83ea9e3c45f8e817041bff9f.", - "_rebaselined_2766_await_subscript_emission": "#2766: extractMixedChain now walks THROUGH await and subscript nodes and peels transparent wrappers at loop entry, so sites whose receiver is `repos[0]` or `(await f())` mint a receiver chain where they previously minted none. EMISSION CHANGE: more sites carry `@reference.receiver-chain`; no existing chain changed shape. Only go and kotlin drifted of 15 — the two whose fixture corpora contain such receivers. Prior 8162272bb897b0b89472c406321cf8d88a5ae4ea83ea9e3c45f8e817041bff9f -> c9c908f441e3be12fad2448120ed3ea35dc235a12b3f63b0ec532ffdae11d9e9.", - "_rebaselined_2766_phantom_callee_read_site": "#2766: Go's `@reference.read` pattern matches EVERY selector_expression, so a member call `h.dep.Work()` minted THREE sites — the call, the genuine `h.dep` field read, and a PHANTOM read on the callee `h.dep.Work`. The phantom resolved through findOwnedMember (which prefers methods over fields) and emitted an ACCESSES edge to the METHOD duplicating the CALLS edge at the same position; visible today on any receiver the text cascade can type (`RunFromValueReceiver -> DoWork`). The emitter now drops a read match whose selector is in FUNCTION position. FEWER capture matches for Go, no other language affected — go was the only fingerprint of 15 that moved. A method VALUE (`f := h.dep.Work`) is not in function position and is untouched. Prior c9c908f441e3be12fad2448120ed3ea35dc235a12b3f63b0ec532ffdae11d9e9 -> 7bb524a32a2eed57a15b454e3a33480e92a496c683e6856ef02179693c0e02e3.", + "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 8cba537ff211fab3bac5fb4456cd1ffba14d6a2db75c40acae28ab8bf29f3d2e -> 8162272bb897b0b89472c406321cf8d88a5ae4ea83ea9e3c45f8e817041bff9f.", + "_rebaselined_2766_await_subscript_emission": "#2766: extractMixedChain now walks THROUGH await and subscript nodes and peels transparent wrappers at loop entry, so sites whose receiver is `repos[0]` or `(await f())` mint a receiver chain where they previously minted none. EMISSION CHANGE: more sites carry `@reference.receiver-chain`; no existing chain changed shape. Only go and kotlin drifted of 15 \u2014 the two whose fixture corpora contain such receivers. Prior 8162272bb897b0b89472c406321cf8d88a5ae4ea83ea9e3c45f8e817041bff9f -> c9c908f441e3be12fad2448120ed3ea35dc235a12b3f63b0ec532ffdae11d9e9.", + "_rebaselined_2766_phantom_callee_read_site": "#2766: Go's `@reference.read` pattern matches EVERY selector_expression, so a member call `h.dep.Work()` minted THREE sites \u2014 the call, the genuine `h.dep` field read, and a PHANTOM read on the callee `h.dep.Work`. The phantom resolved through findOwnedMember (which prefers methods over fields) and emitted an ACCESSES edge to the METHOD duplicating the CALLS edge at the same position; visible today on any receiver the text cascade can type (`RunFromValueReceiver -> DoWork`). The emitter now drops a read match whose selector is in FUNCTION position. FEWER capture matches for Go, no other language affected \u2014 go was the only fingerprint of 15 that moved. A method VALUE (`f := h.dep.Work`) is not in function position and is untouched. Prior c9c908f441e3be12fad2448120ed3ea35dc235a12b3f63b0ec532ffdae11d9e9 -> 7bb524a32a2eed57a15b454e3a33480e92a496c683e6856ef02179693c0e02e3.", "_rebaselined_2766_callee_position_marker": "#2766 review fix: a call's callee selector is no longer DROPPED at capture. An earlier commit on this branch dropped it outright, which also deleted the genuine field read on a func-typed struct field (`h.dep.Work()` where `Work func() error`) - callback/hook/mock structs lost their only ACCESSES evidence. The match is now emitted carrying `@reference.callee-position`, and the phantom is suppressed at EMIT by the resolved target's kind instead. Go only: the other 14 languages' fingerprints are byte-identical, which is the check that this is not a cross-language capture change. Prior 7bb524a32a2eed57a15b454e3a33480e92a496c683e6856ef02179693c0e02e3 -> e47302079e17a5e73711bbed5416557b49327cb67e4932008700ec6b8fb468b3; scaling 1.001 < 1.5; fixtures 102 (unchanged), capture_groups_fp 2103.", "_rebaselined_2813_interface_field_dispatch_fixture": "#2813: added test/fixtures/lang-resolution/go-interface-field-dispatch/ (8 Go files) as the committed regression fixture for calls through an interface-typed struct field. Go fixture_count 102 -> 110. FIXTURE-CORPUS GROWTH, NOT A CAPTURE CHANGE: the accompanying fixes are a detection-time method-set change (interface-impls.ts) and a resolution-time fan-out in the shared receiver pass, neither of which emits captures; go/query.ts and go/captures.ts are untouched. Go was the ONLY language whose fingerprint drifted, and every other language matched its baseline on the same run - the same check used for the #2766 fixture growth above. Prior e47302079e17a5e73711bbed5416557b49327cb67e4932008700ec6b8fb468b3 -> cffee41cadbf350855d99bd5aee7c015b1e8b31d1c343d02f113540abe86c765; scaling 1.074 < 1.5, capture_groups_fp 2303.", - "_rebaselined_2837": "#2837: Go struct/interface captures re-anchored from the type_declaration onto the type_spec (@scope.class/@declaration.struct/@declaration.interface in languages/go/query.ts, @definition.struct/@definition.interface in GO_QUERIES). A grouped `type (...)` block used to yield ONE scope and ONE node for every type in it, so each type after the first lost its field typeBindings and every field-receiver call in the file emitted nothing. Capture COUNT is unchanged; only ranges moved, plus the new go-grouped-type-decl fixture. Prior c27fb803598581fa4eb7ddf5ef6f8369b9e3a150082d11362e7aa3ec8faaa832 -> e386598526e502d131e52a17d219635b3a4196d94f1ebdd25922a2582c985d18; scaling 1.054 < 1.5." + "_rebaselined_2837": "#2837: Go struct/interface captures re-anchored from the type_declaration onto the type_spec (@scope.class/@declaration.struct/@declaration.interface in languages/go/query.ts, @definition.struct/@definition.interface in GO_QUERIES). A grouped `type (...)` block used to yield ONE scope and ONE node for every type in it, so each type after the first lost its field typeBindings and every field-receiver call in the file emitted nothing. Capture COUNT is unchanged; only ranges moved, plus the new go-grouped-type-decl fixture. Prior c27fb803598581fa4eb7ddf5ef6f8369b9e3a150082d11362e7aa3ec8faaa832 -> e386598526e502d131e52a17d219635b3a4196d94f1ebdd25922a2582c985d18; scaling 1.054 < 1.5.", + "_rebaselined_2873_undecided_satisfaction_fixtures": "#2873: added test/fixtures/lang-resolution/go-extern-qualified-signatures/ (5 Go files) and go-undecided-satisfaction/ (1 Go file) as the committed regression fixtures for out-of-repo package qualifiers in method signatures and for a satisfaction check that cannot be decided. Go fixture_count 116 -> 122. Prior e386598526e502d131e52a17d219635b3a4196d94f1ebdd25922a2582c985d18 -> 9c554a9d698a2b79fb419852daadca87b8aae88180cceabf9c8d82f3e3300f2e. FIXTURE-CORPUS GROWTH, NOT A CAPTURE CHANGE: the accompanying fix is resolution-time (signatureContextForFile recovers an identity for unresolvable imports) plus a tri-state verdict, neither of which runs during capture; go was the ONLY language whose fingerprint drifted and every other language matched its baseline on the same run." }, "cobol": { "fingerprint": "c8c00b56a7da24e04080eb885714fbbf45e3903324f0cf9df0754f5b5a92e3aa", - "_rebaselined_2813_exact_method_sets": "#2813: Go embedded fields now emit `@reference.embedded-pointer` when spelled `*T` rather than `T`. A CAPTURE-EMISSION CHANGE, not fixture growth: fixture_count is unchanged at 110 and capture_groups_fp moves 2303 -> 2339 (+36), which is the new marker plus the WrongSigRepo/Recount rows added to two existing fixture files. The marker is required for exactness — Go gives `struct{ Base }` and `struct{ *Base }` different method sets, so structural interface satisfaction cannot be correct without knowing which was written (go.dev/ref/spec#Struct_types). Go was the ONLY language of 15 whose fingerprint moved, which is the check that this is a Go capture change and not a cross-language regression. Accompanied by SCHEMA_BUMP 39 -> 43 (skipping 40/41/42, taken by origin/main during review) so a warm cache cannot replay the pre-marker capture set. Prior cffee41cadbf350855d99bd5aee7c015b1e8b31d1c343d02f113540abe86c765 -> c27fb803598581fa4eb7ddf5ef6f8369b9e3a150082d11362e7aa3ec8faaa832; scaling 0.987 < 1.5.", + "_rebaselined_2813_exact_method_sets": "#2813: Go embedded fields now emit `@reference.embedded-pointer` when spelled `*T` rather than `T`. A CAPTURE-EMISSION CHANGE, not fixture growth: fixture_count is unchanged at 110 and capture_groups_fp moves 2303 -> 2339 (+36), which is the new marker plus the WrongSigRepo/Recount rows added to two existing fixture files. The marker is required for exactness \u2014 Go gives `struct{ Base }` and `struct{ *Base }` different method sets, so structural interface satisfaction cannot be correct without knowing which was written (go.dev/ref/spec#Struct_types). Go was the ONLY language of 15 whose fingerprint moved, which is the check that this is a Go capture change and not a cross-language regression. Accompanied by SCHEMA_BUMP 39 -> 43 (skipping 40/41/42, taken by origin/main during review) so a warm cache cannot replay the pre-marker capture set. Prior cffee41cadbf350855d99bd5aee7c015b1e8b31d1c343d02f113540abe86c765 -> c27fb803598581fa4eb7ddf5ef6f8369b9e3a150082d11362e7aa3ec8faaa832; scaling 0.987 < 1.5.", "scaling_budget": 1.5, "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: COBOL procedure-pointer callable flow facts; multi-topic extraction now consumes each grouped scope/declaration match once instead of requiring a duplicate declaration-only match. Prior 68ee0e95eb9f86f2d92ca35f730f4c2d4d83abc1b5241ae767ff3437780ec8d1 -> d45bb091b0893d0de4fae2486b31ba21719c9377bf35a0908fd3a36fa1c3bf4e; scaling 0.853 < 1.5.", "_note": "Updated for F17-F23 fixes (P2: TIMES guard, ADD GIVING, SQL AS alias). See PR #1959.", - "_rebaselined_2793_declaratives": "PR #2793: corpus-only re-baseline. `cobol-declaratives` was added to test/fixtures/lang-resolution to reproduce the `Namespace→Record` analyze abort (DECLARATIVES / USE AFTER STANDARD ERROR ON ), and this bench globs `lang-resolution/cobol-*`, so the corpus grew 14 -> 15 files. Verified capture-neutral: with that one fixture moved aside the fingerprint is byte-identical to the prior d45bb091b0893d0de4fae2486b31ba21719c9377bf35a0908fd3a36fa1c3bf4e. No COBOL capture code changed in that PR. Scaling 0.677 < 1.5." + "_rebaselined_2793_declaratives": "PR #2793: corpus-only re-baseline. `cobol-declaratives` was added to test/fixtures/lang-resolution to reproduce the `Namespace\u2192Record` analyze abort (DECLARATIVES / USE AFTER STANDARD ERROR ON ), and this bench globs `lang-resolution/cobol-*`, so the corpus grew 14 -> 15 files. Verified capture-neutral: with that one fixture moved aside the fingerprint is byte-identical to the prior d45bb091b0893d0de4fae2486b31ba21719c9377bf35a0908fd3a36fa1c3bf4e. No COBOL capture code changed in that PR. Scaling 0.677 < 1.5." }, "c": { "fingerprint": "3418cded9f7072152f68992f0a426f43ae7d9d553579a47075fc0cab185848a5", @@ -29,15 +30,15 @@ "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 57fee292147ae6d2db7062da1e07d17122cf355207c8967fa85fd2ec9ca398a4 -> 3418cded9f7072152f68992f0a426f43ae7d9d553579a47075fc0cab185848a5; scaling 1.073 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: C function-pointer signatures plus direct-callee argument metadata and invocation-result suppression. Prior 75bcdbbf006bf9bd263c0f5857461b118f39b164e9f821cb0651ad0ec46ef6ae -> 57fee292147ae6d2db7062da1e07d17122cf355207c8967fa85fd2ec9ca398a4; scaling 1.035 < 1.5.", "_rebaselined_callable_flow": "Callable-value-flow facts for C function pointers, copies, pointer-to-pointer cells, arguments, and indirect invokes. Prior 12a196b2d6249c8d86a931b12ecebc2a0cdf8d6f47683acdd0d8e9d8bc7657f5 -> 75bcdbbf006bf9bd263c0f5857461b118f39b164e9f821cb0651ad0ec46ef6ae; measured scaling ratio 0.980 < 1.5.", - "_added": "#1956: c added to the scope-capture bench (was UNBENCHED). C has no inheritance — flat scale source. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in c/captures.ts (threaded c.node, byte-identical over c-* fixtures); scaling 3.475 -> 0.96.", - "_note": "#1983: + c-static-linkage-worker fixture (caller.c/lib.c/lib.h/local.c — worker-path static-linkage side-channel test). Pure fixture-corpus drift: no c/captures.ts or query change branch-vs-main, existing fixtures' captures byte-identical (c-captures.test.ts 45/45), scaling stays linear (~0.97). The baseline was missed when the fixture landed; regenerated here. fingerprint 0de009b->39f3a83.", + "_added": "#1956: c added to the scope-capture bench (was UNBENCHED). C has no inheritance \u2014 flat scale source. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in c/captures.ts (threaded c.node, byte-identical over c-* fixtures); scaling 3.475 -> 0.96.", + "_note": "#1983: + c-static-linkage-worker fixture (caller.c/lib.c/lib.h/local.c \u2014 worker-path static-linkage side-channel test). Pure fixture-corpus drift: no c/captures.ts or query change branch-vs-main, existing fixtures' captures byte-identical (c-captures.test.ts 45/45), scaling stays linear (~0.97). The baseline was missed when the fixture landed; regenerated here. fingerprint 0de009b->39f3a83.", "_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression)." }, "cpp": { "fingerprint": "bf3587674267be1759e7c45abef143c3b81fe8629cfd17da5f8af40e83cc39ec", "scaling_budget": 1.5, - "_rebaselined_2833_qualified_member_fields": "#2833 follow-up: the six per-qualifier-depth `field_declaration` type-binding rules for a QUALIFIED generic member are replaced by three depth-agnostic ones that match the outer `qualified_identifier` itself, with the qualifier reduced to its top-level tail in `interpret.ts` (`cppQualifiedTail`). This is a CAPTURE-LOGIC change and it moves the fingerprint in two places at once. (1) A qualified NON-generic member (`ns::Address addr;`, `std::string name;`) was captured by nothing at all and now binds — that is the whole +24 on the fixture corpus, every one of them a `std::string` member. (2) Qualifier depth is no longer enumerated, so `a::b::c::Repo` (depth 3+) is captured where the old rules stopped at 2. Capture-name histogram, cpp-* corpus (278 files): `@type-binding.field` 8 -> 32, `@type-binding.name` and `@type-binding.type` 401 -> 425; synthetic DAO-20: `@type-binding.field` 40 -> 60, `@type-binding.name` and `@type-binding.type` 61 -> 81 (= 20 entities x the one `std::string name;` member the DAO unit already declared). NO OTHER TAG MOVED in either set — not one `@declaration.*`, `@scope.*` or `@reference.*` count — which is the property that says three rules replaced six without widening what a field_declaration matches. Measured over the 13 cpp-* fixture repos whose sources gained a binding, the distinct CALLS edge set is byte-identical before and after (32 edges): a reduced tail that names no workspace class binds nothing. Prior bd47c82d09a83cbf0ac857f41876fa31d22304043735582e913bccde06cf2c1a -> db1156d81b3e3341faf5e938a4a34417f4fd246588b6150b4686481823262529; scaling 1.04 < 1.5.", - "_rebaselined_2833_generic_member_fields": "#2833 review follow-up: the cpp DAO generator's unit gains two GENERIC member fields — `Repo repo;` (bare template_type) and `std::vector items;` (qualified_identifier wrapping a template_type) — plus the header declaring `template class Repo`. CORPUS CHANGE, NOT A CAPTURE-LOGIC CHANGE: no extractor edit accompanies it. It exists because the corpus had ZERO template-typed member fields and, across 279 cpp-* fixtures, not one qualified generic member either, so BOTH rounds of new `field_declaration` type-binding rules landed with a byte-identical cpp fingerprint — the gate was structurally blind to the exact thing being changed. Measured under the new corpus, the three states now differ: pre-#2833 query 0e7cbda71360b7ff35dd76091c77f288d6af6a5cfa9185ad85a372aae8c85191 (4521 groups) -> the three template_type field rules de07d8b5300ed867b460918e16b4d80259c7eb6efc1034d32bebe9ff7cab126d (4541) -> the six qualified rules bd47c82d09a83cbf0ac857f41876fa31d22304043735582e913bccde06cf2c1a (4561); under the OLD corpus all three were 856d02f3f9d22cb973877211100aee8e052d4bc545922f78704b1a21ce49ddcc. Capture-name histogram over the synthetic DAO-20: `@type-binding.field` 0 -> 40, `@declaration.field` 40 -> 80, `@type-binding.type`/`@type-binding.name` 20 -> 61, `@declaration.name` 104 -> 147 — 40 = 20 entities x 2 fields, with the residual +1/+2/+3 attributable to the one-off header declaration; every `@reference.*` count is unchanged. Prior 856d02f3f9d22cb973877211100aee8e052d4bc545922f78704b1a21ce49ddcc -> bd47c82d09a83cbf0ac857f41876fa31d22304043735582e913bccde06cf2c1a; scaling 1.058 < 1.5. `c` is unaffected (3418cded..., unchanged).", + "_rebaselined_2833_qualified_member_fields": "#2833 follow-up: the six per-qualifier-depth `field_declaration` type-binding rules for a QUALIFIED generic member are replaced by three depth-agnostic ones that match the outer `qualified_identifier` itself, with the qualifier reduced to its top-level tail in `interpret.ts` (`cppQualifiedTail`). This is a CAPTURE-LOGIC change and it moves the fingerprint in two places at once. (1) A qualified NON-generic member (`ns::Address addr;`, `std::string name;`) was captured by nothing at all and now binds \u2014 that is the whole +24 on the fixture corpus, every one of them a `std::string` member. (2) Qualifier depth is no longer enumerated, so `a::b::c::Repo` (depth 3+) is captured where the old rules stopped at 2. Capture-name histogram, cpp-* corpus (278 files): `@type-binding.field` 8 -> 32, `@type-binding.name` and `@type-binding.type` 401 -> 425; synthetic DAO-20: `@type-binding.field` 40 -> 60, `@type-binding.name` and `@type-binding.type` 61 -> 81 (= 20 entities x the one `std::string name;` member the DAO unit already declared). NO OTHER TAG MOVED in either set \u2014 not one `@declaration.*`, `@scope.*` or `@reference.*` count \u2014 which is the property that says three rules replaced six without widening what a field_declaration matches. Measured over the 13 cpp-* fixture repos whose sources gained a binding, the distinct CALLS edge set is byte-identical before and after (32 edges): a reduced tail that names no workspace class binds nothing. Prior bd47c82d09a83cbf0ac857f41876fa31d22304043735582e913bccde06cf2c1a -> db1156d81b3e3341faf5e938a4a34417f4fd246588b6150b4686481823262529; scaling 1.04 < 1.5.", + "_rebaselined_2833_generic_member_fields": "#2833 review follow-up: the cpp DAO generator's unit gains two GENERIC member fields \u2014 `Repo repo;` (bare template_type) and `std::vector items;` (qualified_identifier wrapping a template_type) \u2014 plus the header declaring `template class Repo`. CORPUS CHANGE, NOT A CAPTURE-LOGIC CHANGE: no extractor edit accompanies it. It exists because the corpus had ZERO template-typed member fields and, across 279 cpp-* fixtures, not one qualified generic member either, so BOTH rounds of new `field_declaration` type-binding rules landed with a byte-identical cpp fingerprint \u2014 the gate was structurally blind to the exact thing being changed. Measured under the new corpus, the three states now differ: pre-#2833 query 0e7cbda71360b7ff35dd76091c77f288d6af6a5cfa9185ad85a372aae8c85191 (4521 groups) -> the three template_type field rules de07d8b5300ed867b460918e16b4d80259c7eb6efc1034d32bebe9ff7cab126d (4541) -> the six qualified rules bd47c82d09a83cbf0ac857f41876fa31d22304043735582e913bccde06cf2c1a (4561); under the OLD corpus all three were 856d02f3f9d22cb973877211100aee8e052d4bc545922f78704b1a21ce49ddcc. Capture-name histogram over the synthetic DAO-20: `@type-binding.field` 0 -> 40, `@declaration.field` 40 -> 80, `@type-binding.type`/`@type-binding.name` 20 -> 61, `@declaration.name` 104 -> 147 \u2014 40 = 20 entities x 2 fields, with the residual +1/+2/+3 attributable to the one-off header declaration; every `@reference.*` count is unchanged. Prior 856d02f3f9d22cb973877211100aee8e052d4bc545922f78704b1a21ce49ddcc -> bd47c82d09a83cbf0ac857f41876fa31d22304043735582e913bccde06cf2c1a; scaling 1.058 < 1.5. `c` is unaffected (3418cded..., unchanged).", "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature/cv metadata. Prior dde874d2c30bda9f634f9799281a66de800cad9f76cf65e7c31839e2ae9da9ff -> 57860dd2a8d4b06c6d2dd0d854c08b781faee3da8f2b6c42ba0c68a9f70e5ccb; scaling 1.090 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: C++ overload-aware function/reference/member-pointer flow facts with invocation/constructor-result suppression. Prior 3a503a1513e7eede3f7a223dcce0896c06d15bdfa920445224c9025848c0d710 -> dde874d2c30bda9f634f9799281a66de800cad9f76cf65e7c31839e2ae9da9ff; scaling 1.034 < 1.5.", "_rebaselined_callable_flow": "Callable-value-flow facts for C++ function pointers/references, reference aliases, contextual arity, arguments, and member-pointer syntax. Prior 6ab657c8f9bfe988a3759098c2cffdcc0443def75ff263f1282b82c21d96e931 -> 3a503a1513e7eede3f7a223dcce0896c06d15bdfa920445224c9025848c0d710; measured scaling ratio 1.069 < 1.5.", @@ -45,11 +46,11 @@ "_note_1899_followup": "#1899 follow-up: braced-init metadata now carries element count, intentionally changing C++ capture output; CI benchmark scaling remains linear (1.129 < 1.5).", "_added": "#1956: cpp added to the scope-capture bench (was UNBENCHED). Heritage-bearing scale source (: public Base, public Mixin) drives emitCppInheritanceCaptures at scale. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in cpp/captures.ts (~12 sites, threaded c.node, byte-identical over 263 cpp-* fixtures); scaling 2.30 -> 1.12.", "_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression). #2094: deleted C++ declarations retain @declaration.is-deleted metadata; deleted operator and pointer-return shapes plus the expanded deleted-overload fixture are included. Intended capture drift; scaling remains linear (1.139 < 1.5).", - "_note": "#1975: + cpp-out-of-line-class fixture, fixture_count 263->265. #1990: + cpp-adl-ns-plus-hidden-friend-same-name fixture (ADL hidden-friend + namespace-callable merge parity test). Pure fixture-corpus drift — no scope-extractor change; existing fixtures' captures byte-identical. fixture_count 265->267. #1995: + cpp-union-nested-tail-collision and cpp-anon-ns-tail-collision fixtures — pure fixture-corpus drift; fixture_count 270->272, fingerprint 538e8be->d63ded6. #1993: + cpp-cross-namespace-same-tail fixture — pure fixture-corpus drift; fixture_count 272->273, fingerprint d63ded6->6d6207ae. #2077 review follow-up: cpp-member-lattice adds cross-file, qualified-base, nested-template, inherited-using, this-receiver, and non-virtual-override regressions; fixture_count 274->275. Capture scaling remains linear (1.134 < 1.5). #1899: braced-init call arguments emit a conservative parameter-type capture; fixture_count 277, scaling remains linear (1.141 < 1.5).", + "_note": "#1975: + cpp-out-of-line-class fixture, fixture_count 263->265. #1990: + cpp-adl-ns-plus-hidden-friend-same-name fixture (ADL hidden-friend + namespace-callable merge parity test). Pure fixture-corpus drift \u2014 no scope-extractor change; existing fixtures' captures byte-identical. fixture_count 265->267. #1995: + cpp-union-nested-tail-collision and cpp-anon-ns-tail-collision fixtures \u2014 pure fixture-corpus drift; fixture_count 270->272, fingerprint 538e8be->d63ded6. #1993: + cpp-cross-namespace-same-tail fixture \u2014 pure fixture-corpus drift; fixture_count 272->273, fingerprint d63ded6->6d6207ae. #2077 review follow-up: cpp-member-lattice adds cross-file, qualified-base, nested-template, inherited-using, this-receiver, and non-virtual-override regressions; fixture_count 274->275. Capture scaling remains linear (1.134 < 1.5). #1899: braced-init call arguments emit a conservative parameter-type capture; fixture_count 277, scaling remains linear (1.141 < 1.5).", "_rebaselined_2522_review_fixes": "PR #2522 review fixes: outermost-chain passing modes; ->* ERROR-recovery role order; member-store visibility. Prior 57860dd2a8d4b06c6d2dd0d854c08b781faee3da8f2b6c42ba0c68a9f70e5ccb -> f29bc3f7b1622954d6f6b7647bc9cf6c7a2629ffcc0fe00ac7918e4925876b65; scaling ratio re-verified within budget.", - "_rebaselined_2522_prototype_value_cells": "Plain function/method prototypes no longer index as callable value cells (only pointer/parenthesized variable declarators do) — removes the spurious indirect-invoke facts that leaked phantom CALLS past two-phase suppression. Prior f29bc3f7b1622954d6f6b7647bc9cf6c7a2629ffcc0fe00ac7918e4925876b65 -> a70625bb0a9ef74e760d9d79cc5557485d0f0d3fb935e8a22a0c9556c65b5bb1; scaling re-verified within budget.", + "_rebaselined_2522_prototype_value_cells": "Plain function/method prototypes no longer index as callable value cells (only pointer/parenthesized variable declarators do) \u2014 removes the spurious indirect-invoke facts that leaked phantom CALLS past two-phase suppression. Prior f29bc3f7b1622954d6f6b7647bc9cf6c7a2629ffcc0fe00ac7918e4925876b65 -> a70625bb0a9ef74e760d9d79cc5557485d0f0d3fb935e8a22a0c9556c65b5bb1; scaling re-verified within budget.", "_rebaselined_receiver_chain_2747": "#2747: additionally adds the `cpp-receiver-chain-arrow` fixture, the behavioural proof for a `->` BASE receiver (`svc->getUser()->save()`) that the rollout fixed and that `cpp-chain-call/` could never catch because it uses the value `.` form. Prior a70625bb0a9ef74e760d9d79cc5557485d0f0d3fb935e8a22a0c9556c65b5bb1 -> 7e27aea46f3e17f33c41babbe0ddd982d1ab5920f143864763e0a1c6aef882a5.", - "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 7e27aea46f3e17f33c41babbe0ddd982d1ab5920f143864763e0a1c6aef882a5 -> 856d02f3f9d22cb973877211100aee8e052d4bc545922f78704b1a21ce49ddcc.", + "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 7e27aea46f3e17f33c41babbe0ddd982d1ab5920f143864763e0a1c6aef882a5 -> 856d02f3f9d22cb973877211100aee8e052d4bc545922f78704b1a21ce49ddcc.", "capture_groups_small": 5021, "capture_groups_large": 16021, "capture_groups_fp": 4605, @@ -63,27 +64,28 @@ "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: C# method-group/delegate callable flow facts with invocation-result suppression. Prior 2bb5bc8c19cb8eb08c9590545ad8a1968a7152951f7e12746e2d7901d542fed9 -> f31544530924748f9aa37d11cec570bc10c3ddf9d9b237e6df7a17623fd2bb3a; scaling 1.115 < 1.5.", "_note": "#2046: F35 qualified-constructor captures now emit @reference.qualified-name + a simple-name @reference.name on `new Ns.Foo()`/`new A.B.Foo()`; namespace_declaration/file_scoped_namespace_declaration now emit @declaration.namespace name captures (feeding the non-destructive namespacePrefix sidecar for `new B.Foo()` same-tail disambiguation). + csharp-interface-only-base and csharp-namespace-qualified-ctor fixtures. Pure capture-additive + fixture-corpus drift; scaling stays linear (~1.11).", "_rebaselined_2563_instance_ownership": "#2563: csharp-using-static adds same-file ownership, local-function, overload, partial-class, and cross-namespace same-name coverage. Prior 75cf380209fa7d1a8a3ec873be1a9424b4e5173be0b08234c2291e8521a9b3c1 -> e05dc27456bde8175948586c9e7689033a378fa40e9ca4ce78cce41fbea0f2f8; scaling 1.058 < 1.5.", - "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior 05a85bae70cf9c94f42459c843cfc36e3e81c872e5dcc7d77bc42fbc390f4bfe -> 8a282254b93b3ef2ff34c2fdba819ebc95c53c4fcb09942cbad99f96d3687855.", - "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 8a282254b93b3ef2ff34c2fdba819ebc95c53c4fcb09942cbad99f96d3687855 -> 476d98a7cc659951c315d63319c8077bbcf0e5f3ec12d32ed773992a1f3a2adc.", + "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 05a85bae70cf9c94f42459c843cfc36e3e81c872e5dcc7d77bc42fbc390f4bfe -> 8a282254b93b3ef2ff34c2fdba819ebc95c53c4fcb09942cbad99f96d3687855.", + "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 8a282254b93b3ef2ff34c2fdba819ebc95c53c4fcb09942cbad99f96d3687855 -> 476d98a7cc659951c315d63319c8077bbcf0e5f3ec12d32ed773992a1f3a2adc.", "capture_groups_small": 4259, "capture_groups_large": 13609, "capture_groups_fp": 2657, "fixture_count": 178 }, "rust": { - "fingerprint": "116a971fee0004f340477aff69fa110a1d92bd8ba882d7c926483c6b1e8ca2b9", + "fingerprint": "e61653008ff2de506cfd47f905fa9eb22d82fbbfe94d2a1d8190c358211b57b7", "scaling_budget": 1.5, - "_rebaselined_mod_node_identity_2745_review": "#2745 review: added rust-2742-mod-members, rust-2742-nested-mods and rust-2742-type-vs-module under lang-resolution for the container/owner-edge fix, nested inline modules, and the imported-type-vs-module precedence. emitRustScopeCaptures is unchanged — verified by removing ONLY those three fixture dirs and re-running, which reproduces the prior fingerprint exactly, so the shift is purely corpus growth (fixture_count 196 -> 202, capture_groups_fp 3432 -> 3556). Prior 90fda086a4e13aa069a5981f63ed58ab1c71f1ed3da5e1480a080e1992b0d3e5 -> 05acbaca48427e0d9e0793bcd0ce4057712d3716b5e7868189c12e05ef8dd300; scaling 1.022 local / 1.057 CI < 1.5. NOTE for the next fixture author: a new rust-* fixture drifts BOTH this bench baseline and the rust-captures-golden snapshot. Updating only the golden is how this reached CI red.", + "_rebaselined_generic_instantiation_2912": "#2912: RUST_SCOPE_QUERY tags trait-impl heritage with the instantiation the impl was written with (`impl Validator for V`), so interface dispatch can prune implementors of an instantiation the receiver cannot hold. Additive capture text on existing impl matches \u2014 the same matches are minted, carrying one more field \u2014 so this is digest drift, not a capture-set change: capture_groups_fp (3556) and fixture_count (202) are both unchanged, which is the check that no match appeared or vanished. Prior 116a971fee0004f340477aff69fa110a1d92bd8ba882d7c926483c6b1e8ca2b9 -> e61653008ff2de506cfd47f905fa9eb22d82fbbfe94d2a1d8190c358211b57b7; scaling 1.018 < 1.5. Only rust and dart move; the other 13 languages are byte-identical.", + "_rebaselined_mod_node_identity_2745_review": "#2745 review: added rust-2742-mod-members, rust-2742-nested-mods and rust-2742-type-vs-module under lang-resolution for the container/owner-edge fix, nested inline modules, and the imported-type-vs-module precedence. emitRustScopeCaptures is unchanged \u2014 verified by removing ONLY those three fixture dirs and re-running, which reproduces the prior fingerprint exactly, so the shift is purely corpus growth (fixture_count 196 -> 202, capture_groups_fp 3432 -> 3556). Prior 90fda086a4e13aa069a5981f63ed58ab1c71f1ed3da5e1480a080e1992b0d3e5 -> 05acbaca48427e0d9e0793bcd0ce4057712d3716b5e7868189c12e05ef8dd300; scaling 1.022 local / 1.057 CI < 1.5. NOTE for the next fixture author: a new rust-* fixture drifts BOTH this bench baseline and the rust-captures-golden snapshot. Updating only the golden is how this reached CI red.", "_rebaselined_dyn_trait_object_2604": "#2604: RUST_SCOPE_QUERY now captures function_signature_item (abstract trait methods, no body) as a scope + declaration, so a &dyn Trait receiver can dispatch a CALLS edge to the trait's own method. Additive capture shift across every bench fixture with a required trait method. Prior df369c5a5f8de7753fc8bab8b4108ef5081750974ea5085ba9a867675ac9eb29 -> f7742f65f14d7d6590df7f16303fc3cc9dc0c233cd80bf90c98b084933cd3846; scaling 1.033 < 1.5.", "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 65e5bca66bb1ca117949409e8fb5c80ee69d6f1b5318908eaaecf08da0482e5c -> df369c5a5f8de7753fc8bab8b4108ef5081750974ea5085ba9a867675ac9eb29; scaling 1.065 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Rust fn-value callable flow facts with invocation/constructor-result suppression. Prior ac610bbe97666bf285923479dd7b43a2fe4c5354aae8df1bcbafdc04fb220f82 -> 65e5bca66bb1ca117949409e8fb5c80ee69d6f1b5318908eaaecf08da0482e5c; scaling 1.024 < 1.5.", - "_rebaselined": "#1956 tri-review U1: rust-qualified-trait fixture (scoped + generic-of-scoped impl trait paths); bareTypeIdentifier now resolves scoped_type_identifier bases by their name: tail (additive, no existing-fixture drift); linear (~1.04). #1975: + rust-scoped-impl fixture (impl a::Inner / b::Inner inherent scoped impls) — legacy @definition.impl scoped arm + findEnclosingClassInfo inherent-impl scoped target; rust scope-extractor captures byte-identical. | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.", - "_note": "PR #1934: F66/F68 let-binding pattern narrowing; F71 union (Struct-labeled, now materialized via legacy @definition.struct + resolvable); F72 macro FULLY WIRED — @declaration.macro/@reference.macro + MacroRegistry → USES edges to Macro nodes (never a same-named fn). + rust-macro / rust-union fixtures and merged with origin/main #1975 rust-scoped-impl; fingerprint re-baselined (scaling ~0.99, fixture_count 126). #1992: + rust-nested-tail-collision-generic and rust-generic-impl-same-method-name (F3) fixtures — pure fixture-corpus drift, no scope-extractor change; fixture_count 127->129, fingerprint 56ffc1c0->b00aea0f.", + "_rebaselined": "#1956 tri-review U1: rust-qualified-trait fixture (scoped + generic-of-scoped impl trait paths); bareTypeIdentifier now resolves scoped_type_identifier bases by their name: tail (additive, no existing-fixture drift); linear (~1.04). #1975: + rust-scoped-impl fixture (impl a::Inner / b::Inner inherent scoped impls) \u2014 legacy @definition.impl scoped arm + findEnclosingClassInfo inherent-impl scoped target; rust scope-extractor captures byte-identical. | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.", + "_note": "PR #1934: F66/F68 let-binding pattern narrowing; F71 union (Struct-labeled, now materialized via legacy @definition.struct + resolvable); F72 macro FULLY WIRED \u2014 @declaration.macro/@reference.macro + MacroRegistry \u2192 USES edges to Macro nodes (never a same-named fn). + rust-macro / rust-union fixtures and merged with origin/main #1975 rust-scoped-impl; fingerprint re-baselined (scaling ~0.99, fixture_count 126). #1992: + rust-nested-tail-collision-generic and rust-generic-impl-same-method-name (F3) fixtures \u2014 pure fixture-corpus drift, no scope-extractor change; fixture_count 127->129, fingerprint 56ffc1c0->b00aea0f.", "_rebaselined_import_disambiguation_2514": "#2514: added rust-import-* and rust-dup-* fixtures under lang-resolution for the range-binding ambiguity latch + import-disambiguated resolution (for-loops / struct destructuring across explicit/aliased/glob use imports). emitRustScopeCaptures is unchanged; the corpus fingerprint shifts purely because the fixture set grew (130 -> 174). Prior f7742f65f14d7d6590df7f16303fc3cc9dc0c233cd80bf90c98b084933cd3846 -> 655aed01cf1b6b84fa0c64d48dfb2526ecb67f47d90f0a91edabacd269a212db; scaling 1.06 < 1.5.", - "_rebaselined_self_type_binding_2714": "#2714: a Rust `Self` type binding now records the enclosing impl's type instead of the literal 'Self'. `let fresh = Self { .. }` inside `impl User` binds `fresh: User`; recorded verbatim it bound `fresh: Self`, which resolves to nothing. The type-env channel already substituted this (type-extractors/rust.ts findEnclosingImplType); the scope-resolution channel did not, so the two disagreed. The gap was invisible while lookupCore Step 1 still walked the lexical chain for NAMED receivers — the impl scope binds the method by name, so fresh.validate() resolved by accident — and became a lost CALLS edge when #2714 stopped that walk. Only the rust fingerprint moves; the other 14 languages are byte-identical.", + "_rebaselined_self_type_binding_2714": "#2714: a Rust `Self` type binding now records the enclosing impl's type instead of the literal 'Self'. `let fresh = Self { .. }` inside `impl User` binds `fresh: User`; recorded verbatim it bound `fresh: Self`, which resolves to nothing. The type-env channel already substituted this (type-extractors/rust.ts findEnclosingImplType); the scope-resolution channel did not, so the two disagreed. The gap was invisible while lookupCore Step 1 still walked the lexical chain for NAMED receivers \u2014 the impl scope binds the method by name, so fresh.validate() resolved by accident \u2014 and became a lost CALLS edge when #2714 stopped that walk. Only the rust fingerprint moves; the other 14 languages are byte-identical.", "_rebaselined_module_tree_2730": "#2730 + #2741 review: RUST_SCOPE_QUERY captures mod_item as @declaration.namespace (a Rust module is an item, mirroring the C++ namespace_definition capture) and tags scoped call sites with @reference.qualified-name so the written path survives to resolution. Both are additive captures: every bench fixture holding a mod block or a Foo::bar() call gains groups, and the corpus also grew by the rust-2730-* fixtures added for the fix and its review (workspace-crates, type-qualified, gaps, samename-wrapper, crate-layout). Prior 7f1240b38457468f06b7931e0c2c578f218f922774d0dc7e2ee6ef3b08d4d689 -> 90fda086a4e13aa069a5981f63ed58ab1c71f1ed3da5e1480a080e1992b0d3e5; scaling 1.061 < 1.5; fixture_count 196. Only the rust fingerprint moves; the other 14 languages are byte-identical. The earlier revision of this note cited 655aed01... as the prior value, which was two rebaselines stale (it predates #2604 and #2714); the CI gate compares live fingerprints, not this prose, so nothing caught it.", - "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior 05acbaca48427e0d9e0793bcd0ce4057712d3716b5e7868189c12e05ef8dd300 -> 83812d82f0e2c3eb552f3246381ca3dd5ccd6783d63aba3325f1343e7772280c.", - "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 83812d82f0e2c3eb552f3246381ca3dd5ccd6783d63aba3325f1343e7772280c -> 6174889b8c98e0af430fa54c268dc781989ca9a8172d690eebae37a95f77e809.", + "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 05acbaca48427e0d9e0793bcd0ce4057712d3716b5e7868189c12e05ef8dd300 -> 83812d82f0e2c3eb552f3246381ca3dd5ccd6783d63aba3325f1343e7772280c.", + "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 83812d82f0e2c3eb552f3246381ca3dd5ccd6783d63aba3325f1343e7772280c -> 6174889b8c98e0af430fa54c268dc781989ca9a8172d690eebae37a95f77e809.", "capture_groups_small": 5507, "capture_groups_large": 17607, "capture_groups_fp": 3556, @@ -95,9 +97,9 @@ "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior df7b1565f9115d66b1ae32e4a408d651afb2521b14e5ca615f3be426c29af618 -> 4a688fa5a7016546f7f3c6d44de023608ae80c5b0e3670c16f6e61b3632608fd; scaling 1.078 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: PHP first-class callable and variable-invocation flow facts with invocation-result suppression. Prior 31c9e3f3cb7094a2bf9021cf9db859036e002f8b44605cd993b470fc600e97cb -> df7b1565f9115d66b1ae32e4a408d651afb2521b14e5ca615f3be426c29af618; scaling 1.074 < 1.5.", "_rebaselined": "#1956: heritage-bearing scale source (class extends Base + use trait); both forms gated at scale; linear (~1.04). | #2481/#2482: PHP imports carry a symbol-kind capture so function/constant imports resolve by declaring file; capture shape changes, scaling remains linear (~1.04).", - "_note": "PR #1931: F53 import multi-clause, F54 enum_case, F55 anonymous_class — fixture count 138→140, fingerprint drift expected.", - "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior 4a688fa5a7016546f7f3c6d44de023608ae80c5b0e3670c16f6e61b3632608fd -> 3745662053c76b6ae0a84a29aad319626ed5ccb88f7b9376c2680d3dc6502e28.", - "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 3745662053c76b6ae0a84a29aad319626ed5ccb88f7b9376c2680d3dc6502e28 -> b213a872342da2d866b04681dede988770e4d3dfdc0d6e9f62212ec5b59cdc2c." + "_note": "PR #1931: F53 import multi-clause, F54 enum_case, F55 anonymous_class \u2014 fixture count 138\u2192140, fingerprint drift expected.", + "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 4a688fa5a7016546f7f3c6d44de023608ae80c5b0e3670c16f6e61b3632608fd -> 3745662053c76b6ae0a84a29aad319626ed5ccb88f7b9376c2680d3dc6502e28.", + "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 3745662053c76b6ae0a84a29aad319626ed5ccb88f7b9376c2680d3dc6502e28 -> b213a872342da2d866b04681dede988770e4d3dfdc0d6e9f62212ec5b59cdc2c." }, "ruby": { "fingerprint": "1c8c9c4b54036fa24c2a81e39ea530e938645c856d369075e5f437da78218c57", @@ -105,10 +107,10 @@ "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior cff273ae6cb7232c977d9241581834a2a2fa8bcf6369f7bd8f2471cd4419a6ef -> bf50ec6a53c8c91680dc6feac63a8956e78b1059249232dc25a0cfed25f31236; scaling 1.103 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Ruby Method/Proc callable flow facts with invocation/constructor-result suppression. Prior b5ea93bb3d0469c3821a8c70f5d5991c6f326e41097c119ad691154301dcc753 -> cff273ae6cb7232c977d9241581834a2a2fa8bcf6369f7bd8f2471cd4419a6ef; scaling 1.086 < 1.5.", "_rebaselined": "#1956 synth-widening: + ruby-qualified-base fixture; synth now reduces a scope_resolution superclass (class C < Mod::Super) to its trailing constant (matching the #1940 legacy leg), at parity. Linear (~1.03). (Earlier #1956: heritage-bearing scale source.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.", - "_note": "F62: + scope_resolution class/module declaration captures — fixture count 78→81, fingerprint drift expected. #1975: + ruby-tail-collision fixture (Foo::Bar vs Baz::Bar stay distinct nodes) — pure fixture-corpus drift, scope-extractor captures unchanged; 81→82. #1991: + ruby-nested-mixin-tail-collision fixture (85→86). Recomputed on the #942 merge (fixture-comment rewording shifts capture byte-positions, capture LOGIC unchanged): bf6b13a -> b5ea93bb.", + "_note": "F62: + scope_resolution class/module declaration captures \u2014 fixture count 78\u219281, fingerprint drift expected. #1975: + ruby-tail-collision fixture (Foo::Bar vs Baz::Bar stay distinct nodes) \u2014 pure fixture-corpus drift, scope-extractor captures unchanged; 81\u219282. #1991: + ruby-nested-mixin-tail-collision fixture (85\u219286). Recomputed on the #942 merge (fixture-comment rewording shifts capture byte-positions, capture LOGIC unchanged): bf6b13a -> b5ea93bb.", "_rebaselined_2522_review_fixes": "PR #2522 review fixes: bare identifiers are calls, not callable references (bareNamesAreCalls). Prior bf50ec6a53c8c91680dc6feac63a8956e78b1059249232dc25a0cfed25f31236 -> 070e4e11502442998ddf4048c2981cf1b2b735a87362ff854c5d14d71f98f4e2; scaling ratio re-verified within budget.", - "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior fea3edf82f521995147874b7f6c5f9e2eb88efdebf6365668f3260e913f0b558 -> fc81941b0a921074fa80dc448284de9a23bd07358ddc84d4894797cc08c3fe83.", - "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior fc81941b0a921074fa80dc448284de9a23bd07358ddc84d4894797cc08c3fe83 -> 1c8c9c4b54036fa24c2a81e39ea530e938645c856d369075e5f437da78218c57." + "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior fea3edf82f521995147874b7f6c5f9e2eb88efdebf6365668f3260e913f0b558 -> fc81941b0a921074fa80dc448284de9a23bd07358ddc84d4894797cc08c3fe83.", + "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior fc81941b0a921074fa80dc448284de9a23bd07358ddc84d4894797cc08c3fe83 -> 1c8c9c4b54036fa24c2a81e39ea530e938645c856d369075e5f437da78218c57." }, "swift": { "fingerprint": "adef9284feaecd39cb490aebce83876e15b9150c7a04b00a396feb78b7e1e0a9", @@ -117,13 +119,14 @@ "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Swift function-value callable flow facts with invocation-result suppression. Prior 180ac68e780bdf6f9089d53f51cbb9a66aed3e7774631cc3fcbaae5020213998 -> 5f923c6604d825d12b249f31c155b0f4d13a8379d532e5dde64a0f9b15cf4725; scaling 1.043 < 1.5.", "_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression).", "_rebaselined_2522_review_fixes": "PR #2522 review fixes: assignment target:/result: fields join the shared fallback. Prior 7687ee2466e16020a12440a03fbda53e63aa05f94b4481f6133c09867a0d560d -> 115c5da807e36bb12fdeba28e44f2b6484ef322ff26c19fa0f191febaf774248; scaling ratio re-verified within budget.", - "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior 115c5da807e36bb12fdeba28e44f2b6484ef322ff26c19fa0f191febaf774248 -> a6fca5f052ae5ec635b56051e28a168c864a988b2221a3279ddd69807378ba0b.", - "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior a6fca5f052ae5ec635b56051e28a168c864a988b2221a3279ddd69807378ba0b -> 2f04ae960123cf50138a49fabdc5a146c2963170cecf5755c552b23c9055a9e7.", + "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 115c5da807e36bb12fdeba28e44f2b6484ef322ff26c19fa0f191febaf774248 -> a6fca5f052ae5ec635b56051e28a168c864a988b2221a3279ddd69807378ba0b.", + "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior a6fca5f052ae5ec635b56051e28a168c864a988b2221a3279ddd69807378ba0b -> 2f04ae960123cf50138a49fabdc5a146c2963170cecf5755c552b23c9055a9e7.", "_rebaselined_inferred_field_receiver_2807": "#2807: optional property annotations (`var a: Outer?`) now emit a type binding. The prior pattern required the `user_type` to be a DIRECT child of the annotation, so an `optional_type` wrapper meant an optional field was never typed at all and its receiver could not resolve. ADDS @type-binding.annotation captures on the optional form only; no capture is removed. Prior 2f04ae960123cf50138a49fabdc5a146c2963170cecf5755c552b23c9055a9e7 -> adef9284feaecd39cb490aebce83876e15b9150c7a04b00a396feb78b7e1e0a9; scaling 1.023 < 1.5." }, "dart": { - "fingerprint": "ba93c90dcd341259e8e088816bc8c76ad27882419f665e35c056dc22fa54cf73", + "fingerprint": "3a8ddabbeb1cba47a4757451d4f79d726ca230fd15e860772b11526fbb1c6687", "scaling_budget": 1.5, + "_rebaselined_generic_instantiation_2912": "#2912: the Dart heritage marker carries a fourth field \u2014 the type arguments the clause was written with (`implements Validator`) \u2014 so interface dispatch can prune implementors of a mismatched instantiation. Additive marker text on existing heritage matches rather than a new match, so this is digest drift only; a marker from a pre-#2912 cache simply has no fourth field and reads as unknown. Prior ba93c90dcd341259e8e088816bc8c76ad27882419f665e35c056dc22fa54cf73 -> 3a8ddabbeb1cba47a4757451d4f79d726ca230fd15e860772b11526fbb1c6687; scaling 1.027 < 1.5.", "_rebaselined_2538": "#2538: Dart extension type headers are preprocessed into normal extension declarations before scope capture, so extension type symbols and their methods are now emitted. Intentional Dart-only capture fingerprint drift; CI measured scaling 1.042 < 1.5.", "_rebaselined_2538_implements": "#2538 tri-review follow-up: Dart extension type implements clauses now emit heritage markers and fixture coverage asserts IMPLEMENTS edges, including multi-arg generic interfaces. Prior committed baseline 66a46d5ff09f3d11b2771db0f48596fe7057e95c5bc8f56241fdb911137298c3 -> ba93c90dcd341259e8e088816bc8c76ad27882419f665e35c056dc22fa54cf73; scaling 0.945 < 1.5.", "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 29ce2bfe70b246b1c9d5e99c0ec11e850c22e9672737592207242b7f4cc824b8 -> 66a46d5ff09f3d11b2771db0f48596fe7057e95c5bc8f56241fdb911137298c3; scaling 1.054 < 1.5.", @@ -132,11 +135,14 @@ "_rebaselined": "#1919 review CF3 fix: extended kotlin-local-property-owner (init/accessor destructuring) + new dart-accessor-owner fixture (getter/setter ownership). Fingerprint-only corpus drift; scaling ~1.0." }, "java": { - "fingerprint": "b29e263524f55151dcb7cfc4c929d3d1d7bb360355cee4e832158f927857f663", + "fingerprint": "2bf47cc19b595a9889ac21ec0154c6ce6786271d68551f21d1bc14c626bcd4ff", "scaling_budget": 1.5, + "_rebaselined_2935_synthetic_declarations": "PR #2935 review follow-up: synthesized Java anonymous classes and bodied enum constants now carry the presence-only @declaration.is-synthetic sidecar used to preserve source-written dispatch targets at the fanout cap. DIGEST DRIFT ONLY, NOT A CAPTURE-SET CHANGE: the tag is attached to existing synthetic declaration matches; capture groups and fixture count remain 5755/18405, 3512, and 206. Prior 36d689c58526c4482fbd701d1d9ca156623a3970734ead145717858712271ab5 -> 2e2150b4f4d64519e3f4c6d7a2c12259178d3117872203c904fab8cba96a694a; CI scaling 0.971 < 1.5.", + "_rebaselined_2917_record_component_accessors": "#2917: every implicit Java record-component accessor now emits a component-bounded @scope.function plus @declaration.method/name/zero-arity/return-type metadata. The scope boundary prevents subsequent record-body references from being attributed to the accessor. Java was the only general language fingerprint to move; capture groups scale by exactly two per generated record component (small 5755 -> 6255, large 18405 -> 20005). Prior 36d689c58526c4482fbd701d1d9ca156623a3970734ead145717858712271ab5 -> 901a66c7dc0f071eeef9e4864b2519e5b58a1a141a1f9a7817ea42f7ff70eafb; scaling 0.961 < 1.5. Re-measured after merging origin/main, which carries #2935's is-synthetic sidecar on top of the same corpus: 2e2150b4f4d64519e3f4c6d7a2c12259178d3117872203c904fab8cba96a694a -> 79dafc369eaeb7183ee8cc1149b1a6c21ad672c7e5b806fe8b0060e5a952c79a; scaling 1.085 < 1.5, capture groups 6255/20005, capture_groups_fp 3560, fixture_count 206 (unchanged by the merge).", + "_rebaselined_2900_record_heritage": "#2900 review follow-up: the Java scale unit now includes a record implementing Marker, so the record-declaration @reference.inherits path is fingerprinted and exercised at scale. Prior b29e263524f55151dcb7cfc4c929d3d1d7bb360355cee4e832158f927857f663 -> 36d689c58526c4482fbd701d1d9ca156623a3970734ead145717858712271ab5; scaling 1.042 < 1.5.", "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata; same-name lexical regions use an O(ancestor-depth) ID-set lookup. Prior d5c59d7dc9e206637515d5aea1163f7c1cdd76410c38c5fe6143d13d19677d6a -> 004a3592998dca1193bd1429a8284513725de7764f2a3eceedaaa984cfd763b4; scaling 0.992 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Java method-reference/SAM callable flow facts with invocation-result suppression. Prior 062d754764aaa8a6772fb90875c710502a63e3e7a300e633942381ed914faada -> d5c59d7dc9e206637515d5aea1163f7c1cdd76410c38c5fe6143d13d19677d6a; scaling 1.074 < 1.5.", - "_rebaselined": "#2357 (supersedes #2353): + java-cast-receiver, java-this-field-chain, java-this-dispatch fixtures (cast-wrapped receivers, this.field chains incl. initializer contexts, bare-this dispatch pinning). Drift is purely fixture-additive: with the three new dirs parked, the fingerprint reproduces the prior baseline byte-identically — no emit/capture change. #1956 synth-widening: + java-iface-extends fixture; synthesizeJavaInheritanceReferences now ALSO walks interface_declaration extends_interfaces (interface IA extends IB, IC), matching the #1940 legacy leg. (Earlier U2+review: java-qualified-base fixture covers 2- AND 3-segment qualified bases guarding the legacy end-anchor; synth tail-resolves scoped bases.) Linear (~1.03). (Earliest: java added to bench, exposed+fixed the O(n^2) findNodeAtRange root-walk; 3.09 -> ~0.99.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.", + "_rebaselined": "#2357 (supersedes #2353): + java-cast-receiver, java-this-field-chain, java-this-dispatch fixtures (cast-wrapped receivers, this.field chains incl. initializer contexts, bare-this dispatch pinning). Drift is purely fixture-additive: with the three new dirs parked, the fingerprint reproduces the prior baseline byte-identically \u2014 no emit/capture change. #1956 synth-widening: + java-iface-extends fixture; synthesizeJavaInheritanceReferences now ALSO walks interface_declaration extends_interfaces (interface IA extends IB, IC), matching the #1940 legacy leg. (Earlier U2+review: java-qualified-base fixture covers 2- AND 3-segment qualified bases guarding the legacy end-anchor; synth tail-resolves scoped bases.) Linear (~1.03). (Earliest: java added to bench, exposed+fixed the O(n^2) findNodeAtRange root-walk; 3.09 -> ~0.99.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.", "_note": "#1928 / #2045: F35 adds qualified + qualified-generic constructor query captures (`new pkg.Foo()`, `new a.b.Foo()`, `new pkg.Box()`); F38 synthesizes `@reference.call.constructor` on `super(...)`/`this(...)` explicit_constructor_invocation nodes; F41 generic-aware stripQualifier in interpret (type-binding normalization). + java-qualified-constructor and java-explicit-constructor fixtures. Pure capture-additive + fixture-corpus drift; scaling stays linear (~1.06).", "_rebaselined_2522_review_fixes": "PR #2522 review fixes: get/test dropped from callableProtocolMethods. Prior 004a3592998dca1193bd1429a8284513725de7764f2a3eceedaaa984cfd763b4 -> f3b4f4b6610e07c3ac90deb1c53d3572b6ad55a36e5d7134984876d30031ff67; scaling ratio re-verified within budget.", "_rebaselined_2550_instance_model": "PR #2549 (#2550): anonymous class bodies emit synthesized @declaration.class/@declaration.name (Worker$N), an @reference.inherits to the constructed type, and receiver @type-binding.* captures; six new java-* fixtures joined the corpus. Prior f3b4f4b6610e07c3ac90deb1c53d3572b6ad55a36e5d7134984876d30031ff67 -> d79c3b92acfc866094981499b977388ca14f90839bca0c040342ab1cec00aa90; scaling 1.058 < 1.5.", @@ -144,40 +150,48 @@ "_rebaselined_2564_record_capture": "PR for #2564: JAVA_QUERIES gained a (record_declaration name: (identifier) @name) @definition.record capture, previously entirely missing (record_declaration had no structure-phase capture at all, unlike class/interface/enum) - a record's methods existed as ownerless Method nodes with no HAS_METHOD edge. Two new java-* fixtures (java-record-methods, java-new-expr-chain-call) joined the corpus. Prior 975b68aaac6d06094260fb0c67f9b1bc03692ba7220669d192aca9dccd5fc0ca -> 85fc7af9c3c1bceac76cb4f27214410b04967682a2eaa7e468e26efd1f4e2537; scaling 1.059 < 1.5.", "_rebaselined_2561_enum_constant_receiver": "PR for #2561: synthesizeJavaAnonymousClassDeclarations now emits a class-scope @type-binding.annotation/name/type per enum constant (constant simple name -> its E$N synthesized class when bodied, else the host enum) so E.CONST.method() resolves through the existing compound-receiver chain walk. Two drivers of the drift, both in the java-enum-constant-body fixture (this bench's corpus IS test/fixtures/lang-resolution): (1) one extra type-binding match per enum_constant from the capture change; (2) review follow-up added a body-less Plain.java enum + EnumConst.dispatchToConstant/dispatchInherited methods (bodied-override, inherited-via-MRO, and body-less dispatch call sites). The review's fail-safe hardening (bodied constant binds ONLY to E$N, never the host enum, when name synthesis fails on a malformed tree) is output-neutral on this well-formed corpus (verified: fingerprint identical with and without it). Prior 85fc7af9c3c1bceac76cb4f27214410b04967682a2eaa7e468e26efd1f4e2537 -> d04298a91beec76d0fa7099b3d71265723be60c1df688969aa954f135dd49686; scaling < 1.5.", "_rebaselined_2562_local_classes": "#2562: Java block-local classes, enums, records, and interfaces use source-type-relative JLS 13.1 Host$NLocal identities with javac-compatible per-(host, simple-name) numbering; anonymous numbering remains separate. Lexical aliases begin at each declaration and end with its immediate block. Expanded java-local-class-naming fixtures cover declaration order, disjoint blocks, initializers, lambdas, local type kinds, and recursive local/member/anonymous host chains. Prior d04298a91beec76d0fa7099b3d71265723be60c1df688969aa954f135dd49686 -> 6dd5913a58400a191ff54abf9b852b03d5add657d16c11e60a7c4608ba186197; scaling 1.204 < 1.5.", - "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior 6dd5913a58400a191ff54abf9b852b03d5add657d16c11e60a7c4608ba186197 -> 310adbc2e0827b5ac749acaa981cd12d256fc5b7cbc5592c5bee219e92abf9ee.", - "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 310adbc2e0827b5ac749acaa981cd12d256fc5b7cbc5592c5bee219e92abf9ee -> a9943355e945e03ddb87c800f4cc1f62b3d04feefb3ec64c258d8e0bb3b3fcd9.", - "capture_groups_small": 5005, - "capture_groups_large": 16005, - "capture_groups_fp": 3452, - "fixture_count": 206 + "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 6dd5913a58400a191ff54abf9b852b03d5add657d16c11e60a7c4608ba186197 -> 310adbc2e0827b5ac749acaa981cd12d256fc5b7cbc5592c5bee219e92abf9ee.", + "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 310adbc2e0827b5ac749acaa981cd12d256fc5b7cbc5592c5bee219e92abf9ee -> a9943355e945e03ddb87c800f4cc1f62b3d04feefb3ec64c258d8e0bb3b3fcd9.", + "capture_groups_small": 6255, + "capture_groups_large": 20005, + "capture_groups_fp": 3586, + "fixture_count": 209, + "_rebaselined_2910_declared_package_fixtures": "#2910 adds three Java resolver fixture files covering an external JDK lookalike, a path/package mismatch, and wildcard package membership. Fixture-corpus growth only: Java query rules and synthetic scaling sources are unchanged; capture_groups_small/large remain 6255/20005. capture_groups_fp 3560 -> 3586 and fixture_count 206 -> 209." }, "java-local-types": { - "fingerprint": "8c50bbc83dff4f7f5abd06078aa6abc6b64af05fddb17ee826b5f3df3d346633", + "fingerprint": "bdde823fa725e636e257940efb4c8655aa23124c1727cbaa8856d1ad8f71729e", "scaling_budget": 1.5, + "_rebaselined_2935_synthetic_declarations": "PR #2935 review follow-up: the local-type stress corpus includes synthesized anonymous declarations, which now carry the presence-only @declaration.is-synthetic sidecar. DIGEST DRIFT ONLY, NOT A CAPTURE-SET CHANGE. Prior 8c50bbc83dff4f7f5abd06078aa6abc6b64af05fddb17ee826b5f3df3d346633 -> 560734cd053fb4f4b23aa04bc7870c22089a8deedb0217fa9c1b4db689e02a97; CI scaling 1.002 < 1.5.", + "_rebaselined_2917_record_component_accessors": "#2917: the focused local-type fixture corpus contains local records, so their implicit component accessors add the same bounded scope/declaration captures as the general Java corpus. No local-type naming logic changed. Prior 8c50bbc83dff4f7f5abd06078aa6abc6b64af05fddb17ee826b5f3df3d346633 -> 3e22f368a4ee139be7cb91ff4fb77ddadf60c55efe8d66955ec81f366a46e460; scaling 1.032 < 1.5, capture_groups_fp 680. Re-measured on top of #2935's is-synthetic sidecar after merging origin/main: 560734cd053fb4f4b23aa04bc7870c22089a8deedb0217fa9c1b4db689e02a97 -> bdde823fa725e636e257940efb4c8655aa23124c1727cbaa8856d1ad8f71729e; scaling 0.997 < 1.5, capture_groups_fp 680.", "_added": "#2562 performance follow-up: co-scales same-host, same-name local classes and anonymous classes to gate JLS binary-name ordinal allocation. Precomputed per-sequence ordinals reduce the focused 100->800 workload from 176->6655ms to 141->752ms; normalized 250->800 scaling is 1.054.", - "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior a9ad88de21ca6747a923260dbdf677fb74a004abbf9d57781f745e3a9027530b -> 3ca67847ea2b9a71b0a41e09f943767e5a2d3a113d3e203499ee364e37f40236.", - "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 3ca67847ea2b9a71b0a41e09f943767e5a2d3a113d3e203499ee364e37f40236 -> 8c50bbc83dff4f7f5abd06078aa6abc6b64af05fddb17ee826b5f3df3d346633." + "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior a9ad88de21ca6747a923260dbdf677fb74a004abbf9d57781f745e3a9027530b -> 3ca67847ea2b9a71b0a41e09f943767e5a2d3a113d3e203499ee364e37f40236.", + "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 3ca67847ea2b9a71b0a41e09f943767e5a2d3a113d3e203499ee364e37f40236 -> 8c50bbc83dff4f7f5abd06078aa6abc6b64af05fddb17ee826b5f3df3d346633.", + "capture_groups_fp": 680 }, "typescript": { - "fingerprint": "f66a3e6f1e096431e7046505129a627deaa00ca0de5bc846b080591b397248f7", + "fingerprint": "05d1dadd6c9ef35c74079fa50f341b1b36e4fb02c9a89dd1b59f32b7cfd5e633", "scaling_budget": 1.5, + "_rebaselined_2934_import_type_only": "#2934: `import-decomposer.ts` attaches a presence-only `@import.type-only` synthetic capture to specifiers `tsc` erases, so `check --cycles` can stop counting type-only edges as initialization cycles. DIGEST DRIFT ONLY, NOT A CAPTURE-SET CHANGE \u2014 the tag is added to import matches that already existed, never a new match, the same shape as the #2747 receiver-chain rebaseline. Every count is unchanged: capture_groups_fp 2414, fixture_count 155, capture_groups_small/large 4503/14403 (those measure the SYNTHETIC scaling source, which has no imports at all). The fingerprint moves because `canonicalizeMatch` in measure.mjs hashes every TAG on every match, synthetics included, so one extra presence-only tag on an existing match rewrites that match's canonical string. Attribution is exact, not inferred: neutralizing ONLY the `m['@import.type-only'] = \u2026` assignment in import-decomposer.ts and re-running returns the fingerprint to c2fbf8a89e5686dd\u2026 byte-for-byte, so nothing else in the TypeScript capture stream moved. All 14 other languages report ok. Scaling 0.997 < 1.5. NOTE ON THE CONTROL: javascript did not move (2026993b\u2026, 43 fixtures), but it is a WEAK control here \u2014 `import type` is TypeScript-only syntax, so a JS corpus cannot express the construct and could not have drifted either way. It evidences no collateral damage, not the correctness of the TS change; the exact-attribution check above is what does that. Prior c2fbf8a89e5686dd1ff3659b20d41d8b05ebcc9790356e3653ee0c8ca5d365c8 -> f719163eb03a447c9e40ca316a905dd76cee82192a75a403df478ebbdc13e98f.", "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 27f937bfb47d4bded316ea3c785ff659c8cd88a5761d928f113477a08c802c78 -> e05446620c5b80b7aae291cfdf32f693580fada2ae687124769b04a0c03bfe63; scaling 0.983 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: lexical callable bindings, direct-callee argument metadata, and invocation-result suppression. Prior db5933cc6760234ed7d495123410feba6de243646d583f20d43032b9459f81fd -> 27f937bfb47d4bded316ea3c785ff659c8cd88a5761d928f113477a08c802c78; scaling 0.975 < 1.5.", "_rebaselined_callable_flow": "Callable assignment/copy/formal/argument/invoke facts (also consumed by Vue script blocks). Prior 25de86fd3377132c4e35d3d98f4f94a58e0cfeb7c22948a8ea3be4e793be74fd -> db5933cc6760234ed7d495123410feba6de243646d583f20d43032b9459f81fd; measured scaling ratio 0.951 < 1.5.", - "_rebaselined": "#1962: F44 (class scope@), F85 (enum member declarations), F87 (optional_parameter type annotations) add new captures — fingerprint drift expected.", - "_note": "#1968: F44, F85, F87 — fingerprint drift expected.", + "_rebaselined": "#1962: F44 (class scope@), F85 (enum member declarations), F87 (optional_parameter type annotations) add new captures \u2014 fingerprint drift expected.", + "_note": "#1968: F44, F85, F87 \u2014 fingerprint drift expected.", "_rebaselined_2522": "#2522 intentional @reference.value-ref/property-key capture additions. GitHub Actions run 29553361660 job 87800394279: prior 3f44a4a6892698df2d145c8ff2812c3b318807648983c88aca28fbd694f172f9 -> 25de86fd3377132c4e35d3d98f4f94a58e0cfeb7c22948a8ea3be4e793be74fd; scaling ratio 0.987 < 1.5.", "_rebaselined_2550_instance_model": "PR #2549 (#2545/#2551): object literals emit @scope.object (was unscoped, then @scope.block during development). Prior e05446620c5b80b7aae291cfdf32f693580fada2ae687124769b04a0c03bfe63 -> 3280b13d3f9378ab23eee31c2edc779b5a9ae1e7bb510c23a24855b44406d2f4; scaling 0.981 < 1.5.", - "_rebaselined_receiver_owner_2701": "#2701: every non-arrow function form now carries a `@receiver-owner.this` marker on the same node as `@scope.function`, so a scope that BINDS its own `this` can stop the receiver walk (`Scope.ownsReceivers`). Verified before re-baselining by diffing the capture-name histogram over this same fixture corpus against 1d3088173f6f93827641b476d614d5d15cd4f3ea: the ONLY delta is @receiver-owner.this (typescript +143, javascript +32) — every other capture count is byte-identical, so no existing capture moved. Prior 3280b13d3f9378ab23eee31c2edc779b5a9ae1e7bb510c23a24855b44406d2f4 -> 281e95484203b481094729ca249ef0423c41273eac35e424cdfd032a0dac7699.", - "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior cad25be9f81d6e021ebae8dcb166bc0af3a1ba8021f1506f6ca93fd4c2649000 -> 9e112415f1169f08576826c12ea1d137d1994e34b44c45986c9ffee83b8b4edc.", - "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 9e112415f1169f08576826c12ea1d137d1994e34b44c45986c9ffee83b8b4edc -> cdefe88d3c275f31953216c676ef32c7bf5727d56b9c3840b81ee6bf85749dff.", - "_rebaselined_inferred_field_receiver_2807": "#2807: inference-typed class fields now emit a type binding — `public_field_definition` with a `new_expression` value, and `this. = new ...` carrying a @type-binding.this-field marker. ADDS @type-binding.constructor captures only; no capture is removed, and the annotated form is unchanged because annotation outranks constructor-inferred in typeBindingStrength. Prior cdefe88d3c275f31953216c676ef32c7bf5727d56b9c3840b81ee6bf85749dff -> 248b56f0d7a0a6fc7a949dc7afb8611e135ed642bccc2631b96ebb9d686bb965; scaling 0.994 < 1.5.", - "_rebaselined_ts_heritage_2842": "#2842 review: TypeScript heritage capture now emits `@reference.inherits` for `interface_declaration` (bases on `extends_type_clause`) and `abstract_class_declaration` (bases on `class_heritage`), which were both silently skipped — so `interface B extends A` and `abstract class X implements I` produced no edge and every interface-dispatch walk dead-ended on a bodiless declaration. Verified before re-baselining by diffing the capture-name histogram over this same fixture corpus (145 files) with and without the change: the ONLY deltas are @reference.inherits 17 -> 20 (+3) and its paired @reference.name 245 -> 248 (+3), emitted together by emitTsInheritanceBase. Every other capture count is byte-identical, so no existing capture moved. The +3 is the three `interface X extends BasePayload` declarations in typescript-generic-calls/src/{auth,admin,guest}.ts. javascript is unchanged (no interfaces in the language). Prior 248b56f0d7a0a6fc7a949dc7afb8611e135ed642bccc2631b96ebb9d686bb965 -> 7a960908031331360ce582f5b55b7681e1cd7f8a2eabfd73c00982cb17f2a949.", + "_rebaselined_receiver_owner_2701": "#2701: every non-arrow function form now carries a `@receiver-owner.this` marker on the same node as `@scope.function`, so a scope that BINDS its own `this` can stop the receiver walk (`Scope.ownsReceivers`). Verified before re-baselining by diffing the capture-name histogram over this same fixture corpus against 1d3088173f6f93827641b476d614d5d15cd4f3ea: the ONLY delta is @receiver-owner.this (typescript +143, javascript +32) \u2014 every other capture count is byte-identical, so no existing capture moved. Prior 3280b13d3f9378ab23eee31c2edc779b5a9ae1e7bb510c23a24855b44406d2f4 -> 281e95484203b481094729ca249ef0423c41273eac35e424cdfd032a0dac7699.", + "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior cad25be9f81d6e021ebae8dcb166bc0af3a1ba8021f1506f6ca93fd4c2649000 -> 9e112415f1169f08576826c12ea1d137d1994e34b44c45986c9ffee83b8b4edc.", + "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 9e112415f1169f08576826c12ea1d137d1994e34b44c45986c9ffee83b8b4edc -> cdefe88d3c275f31953216c676ef32c7bf5727d56b9c3840b81ee6bf85749dff.", + "_rebaselined_inferred_field_receiver_2807": "#2807: inference-typed class fields now emit a type binding \u2014 `public_field_definition` with a `new_expression` value, and `this. = new ...` carrying a @type-binding.this-field marker. ADDS @type-binding.constructor captures only; no capture is removed, and the annotated form is unchanged because annotation outranks constructor-inferred in typeBindingStrength. Prior cdefe88d3c275f31953216c676ef32c7bf5727d56b9c3840b81ee6bf85749dff -> 248b56f0d7a0a6fc7a949dc7afb8611e135ed642bccc2631b96ebb9d686bb965; scaling 0.994 < 1.5.", + "_rebaselined_ts_heritage_2842": "#2842 review: TypeScript heritage capture now emits `@reference.inherits` for `interface_declaration` (bases on `extends_type_clause`) and `abstract_class_declaration` (bases on `class_heritage`), which were both silently skipped \u2014 so `interface B extends A` and `abstract class X implements I` produced no edge and every interface-dispatch walk dead-ended on a bodiless declaration. Verified before re-baselining by diffing the capture-name histogram over this same fixture corpus (145 files) with and without the change: the ONLY deltas are @reference.inherits 17 -> 20 (+3) and its paired @reference.name 245 -> 248 (+3), emitted together by emitTsInheritanceBase. Every other capture count is byte-identical, so no existing capture moved. The +3 is the three `interface X extends BasePayload` declarations in typescript-generic-calls/src/{auth,admin,guest}.ts. javascript is unchanged (no interfaces in the language). Prior 248b56f0d7a0a6fc7a949dc7afb8611e135ed642bccc2631b96ebb9d686bb965 -> 7a960908031331360ce582f5b55b7681e1cd7f8a2eabfd73c00982cb17f2a949.", "capture_groups_small": 4503, "capture_groups_large": 14403, - "capture_groups_fp": 2338, - "fixture_count": 151, - "_rebaselined_blind_spots_2856": "#2856 blind-spots series: the JS/TS SCOPE queries gained capture rules, so fingerprint drift is expected and additive. Verified before re-baselining by diffing the capture-name sets in both scope queries against origin/main: TypeScript gained exactly @reference.read.identifier (A2 bare-identifier reads in value positions) and @reference.type (R2-2 type references, so a declared contract stops reporting incoming:{}); JavaScript gained exactly @reference.read.identifier, @reference.read.destructured (R2-1c) and @reference.write.property-key (R2-1b record-construction writes). NOTHING was removed on either side — the delta is a pure superset, which is the check that no existing capture moved. capture_groups_small/large are unchanged (4503/14403) because those measure the SYNTHETIC scaling source, which this branch does not touch; only the fixture-corpus count moves. capture_groups_fp 2097 -> 2338 and fixture_count 146 -> 151 from 21 new lang-resolution fixtures. Scaling stayed linear and inside budget: typescript 1.116 < 1.5, javascript 1.010 < 1.5. Prior typescript ed92588e0fc7b28b3a0174339ac378b4dd85965fe007db1208dea97a65ce0571 -> f66a3e6f1e096431e7046505129a627deaa00ca0de5bc846b080591b397248f7; prior javascript 806f70ad3cce5fc849f6d06a08ace8a95f92a1ea84a2418fddabb1eef5846594 -> 2026993b81b873839dd2ef8797d9c14d9c48516b2b57b05ac17d8d43f2f4eba3." + "capture_groups_fp": 2465, + "fixture_count": 167, + "_rebaselined_blind_spots_2856": "#2856 blind-spots series: the JS/TS SCOPE queries gained capture rules, so fingerprint drift is expected and additive. Verified before re-baselining by diffing the capture-name sets in both scope queries against origin/main: TypeScript gained exactly @reference.read.identifier (A2 bare-identifier reads in value positions) and @reference.type (R2-2 type references, so a declared contract stops reporting incoming:{}); JavaScript gained exactly @reference.read.identifier, @reference.read.destructured (R2-1c) and @reference.write.property-key (R2-1b record-construction writes). NOTHING was removed on either side \u2014 the delta is a pure superset, which is the check that no existing capture moved. capture_groups_small/large are unchanged (4503/14403) because those measure the SYNTHETIC scaling source, which this branch does not touch; only the fixture-corpus count moves. capture_groups_fp 2097 -> 2338 and fixture_count 146 -> 151 from 21 new lang-resolution fixtures. Scaling stayed linear and inside budget: typescript 1.116 < 1.5, javascript 1.010 < 1.5. Prior typescript ed92588e0fc7b28b3a0174339ac378b4dd85965fe007db1208dea97a65ce0571 -> f66a3e6f1e096431e7046505129a627deaa00ca0de5bc846b080591b397248f7; prior javascript 806f70ad3cce5fc849f6d06a08ace8a95f92a1ea84a2418fddabb1eef5846594 -> 2026993b81b873839dd2ef8797d9c14d9c48516b2b57b05ac17d8d43f2f4eba3.", + "_rebaselined_type_parameter_shadowing_w2_8": "W2-8: `@declaration.type-parameters` is now captured on generic FUNCTIONS, generator functions and type ALIASES, not only on class/interface declarations. NO NEW CAPTURE NAME \u2014 verified by diffing the capture-name sets against the wave-1 branch, which returns empty; the tag already existed and simply fires on more declarations. That is the whole delta: capture_groups_fp 2338 -> 2371 (+33 occurrences of an existing tag) and fixture_count 151 -> 152 (one new fixture, typescript-type-parameters). capture_groups_small/large unchanged at 4503/14403, since those measure the synthetic scaling source this does not touch. Scaling 1.06 < 1.5. JavaScript is untouched \u2014 it has no type parameters \u2014 and its fingerprint does not move, which is the check that this is the TS declaration rules and not something broader. Prior f66a3e6f1e096431e7046505129a627deaa00ca0de5bc846b080591b397248f7 -> 62c7f1bfbe568eed927fb78f00061ed5e49d12511fd8260648b876df386f3b4c.", + "_rebaselined_2899_review_type_parameter_scope_fixtures": "PR #2899 review follow-up: FIXTURE-CORPUS GROWTH ONLY \u2014 no query rule changed and no capture name was added or removed. `typescript/query.ts` is byte-identical to the previous baseline; the type-parameter shadowing defect was fixed on the RESOLUTION side (`walkers.ts` gains a `declarationOpenedScope` gate so a declaration's `typeParameters` bind only inside the scope that declaration opened, and the `USES` guard moved from `graph-bridge/references-to-edges.ts` to `resolve-references.ts` where the spelled `site.name` is in hand). The fingerprint moves because measure.mjs fingerprints the whole `lang-resolution/typescript-*` fixture corpus and the regression tests add three files to `typescript-type-parameters/src/` (values.ts, aliased.ts, namespaced.ts) plus two scope-less generic aliases in shapes.ts. Per-file accounting sums exactly to the delta: shapes.ts 33->35 (+2), values.ts +11, aliased.ts +10, namespaced.ts +20 = +43. capture_groups_fp 2371 -> 2414; fixture_count 152 -> 155. capture_groups_small/large unchanged at 4503/14403 (they measure the SYNTHETIC scaling source, untouched). JAVASCRIPT IS THE CONTROL AND DID NOT MOVE (fingerprint 2026993b..., 43 fixtures) \u2014 which is the check that this is corpus growth and not a capture regression; all 14 other languages report `ok`. Scaling 0.976 < 1.5. Prior 62c7f1bfbe568eed927fb78f00061ed5e49d12511fd8260648b876df386f3b4c -> c2fbf8a89e5686dd1ff3659b20d41d8b05ebcc9790356e3653ee0c8ca5d365c8.", + "_rebaselined_2953_workspace_fixture": "#2953 adds test/fixtures/lang-resolution/typescript-pnpm-workspace-imports, a pnpm monorepo of 12 .ts files, and the TypeScript capture corpus is collected from test/fixtures. CORPUS GROWTH ONLY, NOT A CAPTURE CHANGE: fixture_count 155 -> 167 and capture_groups_fp 2414 -> 2465 are the 12 new files' own matches; capture_groups_small/large are unchanged at 4503/14403 because those measure the SYNTHETIC scaling source, which the fixture corpus does not feed. Attribution is exact rather than inferred: moving that one fixture directory aside and re-running returns typescript to f719163eb03a447c9e40ca316a905dd76cee82192a75a403df478ebbdc13e98f byte-for-byte with fixture_count back at 155, and [scope-capture --check] PASSES for all 15 languages - so nothing in the TypeScript capture stream moved. #2953 changes import RESOLUTION, which runs after capture and feeds no capture tag. Prior f719163eb03a447c9e40ca316a905dd76cee82192a75a403df478ebbdc13e98f -> 05d1dadd6c9ef35c74079fa50f341b1b36e4fb02c9a89dd1b59f32b7cfd5e633." }, "javascript": { "fingerprint": "2026993b81b873839dd2ef8797d9c14d9c48516b2b57b05ac17d8d43f2f4eba3", @@ -189,28 +203,31 @@ "_rebaselined": "#1956 synth-widening: + javascript-qualified-base fixture; synthesizeJsInheritanceReferences now handles a member_expression base (class S extends ns.Base -> Base), matching the #1940 legacy leg + the TS terminalTsTypeNameNode property_identifier case, at parity. Linear (~1.05). | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.", "_rebaselined_2522": "#2522 intentional @reference.value-ref/property-key capture additions. GitHub Actions run 29553361660 job 87800394279: prior d72f03c6c502235d2d4b74d66baa5c7d361f040d7a1b72e84acad61210d05ae8 -> 5567dd47e7ba29821a518c4a9852adc3b774e25ef3e7a6e2b3ecb7b59ddab73c; scaling ratio 1.031 < 1.5.", "_rebaselined_2550_instance_model": "PR #2549 (#2545/#2551): object literals emit @scope.object. Prior 479927409bbdd9852a36172c8260aa56df260e99129a7a9c20a0d1903dd5538b -> f1ccf42a36895c8e34dcb724286f247d469835f2dcbb23ad3347190adc7fde1c; scaling 1.096 < 1.5.", - "_rebaselined_receiver_owner_2701": "#2701: every non-arrow function form now carries a `@receiver-owner.this` marker on the same node as `@scope.function`, so a scope that BINDS its own `this` can stop the receiver walk (`Scope.ownsReceivers`). Verified before re-baselining by diffing the capture-name histogram over this same fixture corpus against 1d3088173f6f93827641b476d614d5d15cd4f3ea: the ONLY delta is @receiver-owner.this (typescript +143, javascript +32) — every other capture count is byte-identical, so no existing capture moved. Prior f1ccf42a36895c8e34dcb724286f247d469835f2dcbb23ad3347190adc7fde1c -> 90601494695b834d3a9af7ac4844eac603f4f432809a05554cc59de0674a4354.", - "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior 1c71ef628eb75a3b111afa8c2a7c351c16a7f5aab9fac2f098f82b2866312aa8 -> 83344b7cba093702f4528eeee44e438809c229d43b12e69ed288812ce7ffc7bc.", - "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 83344b7cba093702f4528eeee44e438809c229d43b12e69ed288812ce7ffc7bc -> 806f70ad3cce5fc849f6d06a08ace8a95f92a1ea84a2418fddabb1eef5846594.", - "_rebaselined_blind_spots_2856": "#2856 blind-spots series: the JS/TS SCOPE queries gained capture rules, so fingerprint drift is expected and additive. Verified before re-baselining by diffing the capture-name sets in both scope queries against origin/main: TypeScript gained exactly @reference.read.identifier (A2 bare-identifier reads in value positions) and @reference.type (R2-2 type references, so a declared contract stops reporting incoming:{}); JavaScript gained exactly @reference.read.identifier, @reference.read.destructured (R2-1c) and @reference.write.property-key (R2-1b record-construction writes). NOTHING was removed on either side — the delta is a pure superset, which is the check that no existing capture moved. capture_groups_small/large are unchanged (4503/14403) because those measure the SYNTHETIC scaling source, which this branch does not touch; only the fixture-corpus count moves. capture_groups_fp 2097 -> 2338 and fixture_count 146 -> 151 from 21 new lang-resolution fixtures. Scaling stayed linear and inside budget: typescript 1.116 < 1.5, javascript 1.010 < 1.5. Prior typescript ed92588e0fc7b28b3a0174339ac378b4dd85965fe007db1208dea97a65ce0571 -> f66a3e6f1e096431e7046505129a627deaa00ca0de5bc846b080591b397248f7; prior javascript 806f70ad3cce5fc849f6d06a08ace8a95f92a1ea84a2418fddabb1eef5846594 -> 2026993b81b873839dd2ef8797d9c14d9c48516b2b57b05ac17d8d43f2f4eba3." + "_rebaselined_receiver_owner_2701": "#2701: every non-arrow function form now carries a `@receiver-owner.this` marker on the same node as `@scope.function`, so a scope that BINDS its own `this` can stop the receiver walk (`Scope.ownsReceivers`). Verified before re-baselining by diffing the capture-name histogram over this same fixture corpus against 1d3088173f6f93827641b476d614d5d15cd4f3ea: the ONLY delta is @receiver-owner.this (typescript +143, javascript +32) \u2014 every other capture count is byte-identical, so no existing capture moved. Prior f1ccf42a36895c8e34dcb724286f247d469835f2dcbb23ad3347190adc7fde1c -> 90601494695b834d3a9af7ac4844eac603f4f432809a05554cc59de0674a4354.", + "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 1c71ef628eb75a3b111afa8c2a7c351c16a7f5aab9fac2f098f82b2866312aa8 -> 83344b7cba093702f4528eeee44e438809c229d43b12e69ed288812ce7ffc7bc.", + "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 83344b7cba093702f4528eeee44e438809c229d43b12e69ed288812ce7ffc7bc -> 806f70ad3cce5fc849f6d06a08ace8a95f92a1ea84a2418fddabb1eef5846594.", + "_rebaselined_blind_spots_2856": "#2856 blind-spots series: the JS/TS SCOPE queries gained capture rules, so fingerprint drift is expected and additive. Verified before re-baselining by diffing the capture-name sets in both scope queries against origin/main: TypeScript gained exactly @reference.read.identifier (A2 bare-identifier reads in value positions) and @reference.type (R2-2 type references, so a declared contract stops reporting incoming:{}); JavaScript gained exactly @reference.read.identifier, @reference.read.destructured (R2-1c) and @reference.write.property-key (R2-1b record-construction writes). NOTHING was removed on either side \u2014 the delta is a pure superset, which is the check that no existing capture moved. capture_groups_small/large are unchanged (4503/14403) because those measure the SYNTHETIC scaling source, which this branch does not touch; only the fixture-corpus count moves. capture_groups_fp 2097 -> 2338 and fixture_count 146 -> 151 from 21 new lang-resolution fixtures. Scaling stayed linear and inside budget: typescript 1.116 < 1.5, javascript 1.010 < 1.5. Prior typescript ed92588e0fc7b28b3a0174339ac378b4dd85965fe007db1208dea97a65ce0571 -> f66a3e6f1e096431e7046505129a627deaa00ca0de5bc846b080591b397248f7; prior javascript 806f70ad3cce5fc849f6d06a08ace8a95f92a1ea84a2418fddabb1eef5846594 -> 2026993b81b873839dd2ef8797d9c14d9c48516b2b57b05ac17d8d43f2f4eba3." }, "kotlin": { - "fingerprint": "a184f8ff0ae40d246db855b63f7ff26bda3afac03e5f4c76e4593c7e2cefce54", + "fingerprint": "aeafc7a87402c933786ef582b7c98683b1822b78fa909e605cb97552867fa0d5", "scaling_budget": 1.5, + "_rebaselined_interface_abstract_2885": "#2885: Kotlin interface property accessors stay in the capture set (groups still 5753/18403 and capture_groups_fp 2563) but Method isAbstract is now true for body-less interface properties, which changes accessor-plan identity in the fixture digest. Prior 82ae5e1f750580383344d4c84c400a290474528cd502be4af8cd56705819a683 -> aeafc7a87402c933786ef582b7c98683b1822b78fa909e605cb97552867fa0d5; CI scaling 0.838 < 1.5.", + "_rebaselined_jvm_property_accessors_2885": "#2885: Kotlin val/var properties now emit JVM getter/setter scope and declaration captures, including data-class constructor properties and custom accessors. Synthetic scaling counts move 4753/15203 -> 5753/18403; fixture-corpus groups move 2367 -> 2563. Accessor declaration sidecars use the canonical @declaration.qualified_name key, preserve same-name owner identity, follow JvmAbi is-prefix naming, and suppress @JvmName-renamed accessors until their custom names are modeled. Prior f98e7e936afbce0e99588285cfc603bf945fd58c5de45271860509a5d90eb832 -> 82ae5e1f750580383344d4c84c400a290474528cd502be4af8cd56705819a683; scaling 0.869 < 1.5.", "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior bddba25d5a88152bbbee8d70e82c944b5302accb4b625df782adb1d4f7a7ac12 -> e856951c2a779163d555dadc8e1bf59304a86caed78ac1f450d9caa2b50f63d1; scaling 1.090 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Kotlin callable-reference flow facts with invocation-result suppression. Prior 4900431791f2b9280009deb2b82659c26ead8aa6fb8731190a7c505dec5a9041 -> bddba25d5a88152bbbee8d70e82c944b5302accb4b625df782adb1d4f7a7ac12; scaling 0.880 < 1.5.", "_added": "#1951: bench coverage added (was ungated); scale source heritage-bearing (: Base()); js/kotlin O(n^2) findNodeAtRange-per-match fixed to threaded captured node, now linear.", "_rebaselined": "#1919 review CF3 fix: extended kotlin-local-property-owner (init/accessor destructuring) + new dart-accessor-owner fixture (getter/setter ownership). Fingerprint-only corpus drift; scaling ~1.0.", - "_rebaselined_2271": "PR #2271: re-vendored tree-sitter-kotlin 0.3.8 -> unreleased fwcd main c8ac3d26 for `fun interface` support + new kotlin-fun-interface fixture in the corpus. Drift is both corpus-additive (the fixture) and grammar-driven (the new grammar parses `fun interface` as a class_declaration, not an ERROR node). Baselined to the NEW grammar's fingerprint, so this --check passes only once the regenerated prebuilds land — until then CI loads the committed 0.3.8 binary and the bench is red, same as the kotlin fun-interface integration tests. scaling ~0.83 (linear).", + "_rebaselined_2271": "PR #2271: re-vendored tree-sitter-kotlin 0.3.8 -> unreleased fwcd main c8ac3d26 for `fun interface` support + new kotlin-fun-interface fixture in the corpus. Drift is both corpus-additive (the fixture) and grammar-driven (the new grammar parses `fun interface` as a class_declaration, not an ERROR node). Baselined to the NEW grammar's fingerprint, so this --check passes only once the regenerated prebuilds land \u2014 until then CI loads the committed 0.3.8 binary and the bench is red, same as the kotlin fun-interface integration tests. scaling ~0.83 (linear).", "_rebaselined_2522_review_fixes": "PR #2522 review fixes: fieldless assignment nodes decomposed positionally. Prior e856951c2a779163d555dadc8e1bf59304a86caed78ac1f450d9caa2b50f63d1 -> 4b31f46cfb004ba769a96feeb06ae4ef109c77410f54e7aaab4a688df599b112; scaling ratio re-verified within budget.", "_rebaselined_2550_instance_model": "PR #2549 (#2545): anonymous object expressions (object_literal) emit @scope.class, and the kotlin-object-literal-scope fixture joined the corpus. Prior 4b31f46cfb004ba769a96feeb06ae4ef109c77410f54e7aaab4a688df599b112 -> a6fce0dff00e88d41d85023eaf3f35016b5217c7e5225f24a598e4c70bb63091; scaling 0.951 < 1.5.", "_rebaselined_2563_instance_ownership": "#2563: kotlin-instance-ownership adds unrelated, inherited, outer-instance, and anonymous-object coverage. Prior a6fce0dff00e88d41d85023eaf3f35016b5217c7e5225f24a598e4c70bb63091 -> 9f159f8810d342ef1c821f466efd6920dad9a190f06000056e6cd2815861b195; scaling 1.257 < 1.5.", - "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior 9f159f8810d342ef1c821f466efd6920dad9a190f06000056e6cd2815861b195 -> d3c4d2fa0d82d248a2299cfc888b067187ad1faf2c87a97f93c6ed835eefc3f1.", - "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior d3c4d2fa0d82d248a2299cfc888b067187ad1faf2c87a97f93c6ed835eefc3f1 -> c1f0cc9058ab11b7cd6fc8b440deb6db2b2f530f2eb21178923e68a3d0796c4b.", - "_rebaselined_2766_await_subscript_emission": "#2766: extractMixedChain now walks THROUGH await and subscript nodes and peels transparent wrappers at loop entry, so sites whose receiver is `repos[0]` or `(await f())` mint a receiver chain where they previously minted none. EMISSION CHANGE: more sites carry `@reference.receiver-chain`; no existing chain changed shape. Only go and kotlin drifted of 15 — the two whose fixture corpora contain such receivers. Prior c1f0cc9058ab11b7cd6fc8b440deb6db2b2f530f2eb21178923e68a3d0796c4b -> efd5dbf80ffcd3bab2834d1010f6fe2b239dcc5d58229938dea9cff8d0f380f2.", - "capture_groups_small": 4753, - "capture_groups_large": 15203, - "capture_groups_fp": 2334, - "fixture_count": 137 + "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 9f159f8810d342ef1c821f466efd6920dad9a190f06000056e6cd2815861b195 -> d3c4d2fa0d82d248a2299cfc888b067187ad1faf2c87a97f93c6ed835eefc3f1.", + "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior d3c4d2fa0d82d248a2299cfc888b067187ad1faf2c87a97f93c6ed835eefc3f1 -> c1f0cc9058ab11b7cd6fc8b440deb6db2b2f530f2eb21178923e68a3d0796c4b.", + "_rebaselined_2766_await_subscript_emission": "#2766: extractMixedChain now walks THROUGH await and subscript nodes and peels transparent wrappers at loop entry, so sites whose receiver is `repos[0]` or `(await f())` mint a receiver chain where they previously minted none. EMISSION CHANGE: more sites carry `@reference.receiver-chain`; no existing chain changed shape. Only go and kotlin drifted of 15 \u2014 the two whose fixture corpora contain such receivers. Prior c1f0cc9058ab11b7cd6fc8b440deb6db2b2f530f2eb21178923e68a3d0796c4b -> efd5dbf80ffcd3bab2834d1010f6fe2b239dcc5d58229938dea9cff8d0f380f2.", + "_rebaselined_2960_declared_package_fixture": "#2960 adds four Kotlin declared-package import-resolution fixture files. This is fixture-corpus growth only: fixture_count 137 -> 141 and capture_groups_fp 2334 -> 2367; the synthetic capture counts remain 4753/15203, no Kotlin scope-capture query or implementation changed, and package resolution runs after capture. Other language fingerprints matched their baselines in the same CI run. Prior a184f8ff0ae40d246db855b63f7ff26bda3afac03e5f4c76e4593c7e2cefce54 -> f98e7e936afbce0e99588285cfc603bf945fd58c5de45271860509a5d90eb832.", + "capture_groups_small": 5753, + "capture_groups_large": 18403, + "capture_groups_fp": 2563, + "fixture_count": 141 } } diff --git a/gitnexus/bench/scope-capture/measure.mjs b/gitnexus/bench/scope-capture/measure.mjs index 56887e467..7a4e4057b 100644 --- a/gitnexus/bench/scope-capture/measure.mjs +++ b/gitnexus/bench/scope-capture/measure.mjs @@ -267,14 +267,15 @@ const LANGS = [ fixturePrefix: 'java', exts: ['.java'], file: 'bench.java', - // Java was previously unbenched. Heritage-bearing: extends Base + implements - // Marker (both forms) so the @reference.inherits synth (#1951) is driven at scale. + // Java was previously unbenched. Class and record heritage both implement + // Marker so the @reference.inherits synth (#1951, #2900) is driven at scale. header: 'package generated;\n\nclass Base {}\n\ninterface Marker {}\n\n', unit: (n) => `class Entity${n} extends Base implements Marker {\n` + ` long id = 0L;\n String name = "";\n` + ` public long getId() { return this.id; }\n` + - ` public void setName(String v) { this.name = v; }\n}\n\n`, + ` public void setName(String v) { this.name = v; }\n}\n\n` + + `record RecordEntity${n}(long id) implements Marker {}\n\n`, }, { name: 'java-local-types', diff --git a/gitnexus/bench/spring-config-bindings/baselines.json b/gitnexus/bench/spring-config-bindings/baselines.json new file mode 100644 index 000000000..97f881dd4 --- /dev/null +++ b/gitnexus/bench/spring-config-bindings/baselines.json @@ -0,0 +1,8 @@ +{ + "_comment": "Baselines for bench/spring-config-bindings/measure.mjs --check (#2412). fingerprint is sha256 over position-free Kotlin config-consumer fact ids on the wildcard_large corpus (800 files × 2 @Value properties + 1 @ConfigurationProperties class = 2400 facts). Both arms must fingerprint identically: the wildcard arm adds a sibling nested type named `Value`, which must not suppress the imported Spring annotation. Budgets are timing gates with CI headroom.", + "fingerprint": "34776f883427479befbeb3c09eaae2260ba778e769bff195044d3cb8f5ad9889", + "scaling_budget": 1.6, + "_scaling_note": "(t_large/t_small)/(800/250) on the wildcard arm. Measured ~0.99.", + "widening_overhead_budget": 1.8, + "_widening_overhead_note": "wildcard_large_ms / exact_large_ms. The exact-import control resolves each annotation from imports.exact before any shadow check, so this isolates the wildcard path's per-annotation lexical shadow walk. Measured ~1.09; budget guards against a per-annotation rescan of the file's declarations." +} diff --git a/gitnexus/bench/spring-config-bindings/measure.mjs b/gitnexus/bench/spring-config-bindings/measure.mjs new file mode 100644 index 000000000..94ac94bf2 --- /dev/null +++ b/gitnexus/bench/spring-config-bindings/measure.mjs @@ -0,0 +1,164 @@ +/** + * Build-free throughput + identity bench for Kotlin Spring config-consumer + * capture (#2412). + * + * Arms (identical corpora except the import style): + * - exact: explicit `import ...annotation.Value` control, which resolves the + * annotation from `imports.exact` before any shadow check runs + * - wildcard: `import ...annotation.*` feature path, where every simple-name + * annotation pays the lexical local-type shadow walk. Each file also + * declares a sibling nested type named `Value` that must NOT suppress the + * Spring annotation — the file-wide-shadow regression fixed on this branch. + * + * Parsing is prepared outside the timer; the measured path is the capture + * function the Kotlin worker calls on its own AST. + * + * Usage: + * node --import tsx bench/spring-config-bindings/measure.mjs + * node --import tsx bench/spring-config-bindings/measure.mjs --check + */ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import Parser from 'tree-sitter'; +import { SupportedLanguages } from 'gitnexus-shared'; +import { getLanguageGrammar } from '../../src/core/tree-sitter/parser-loader.ts'; +import { captureKotlinSpringConfigConsumerFacts } from '../../src/core/ingestion/languages/kotlin/spring-config-bindings.ts'; +import { fingerprintIds, minSample, runBaselineCheck } from '../lib/identity-guard.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const BASELINE_PATH = path.resolve(__dirname, 'baselines.json'); + +const SMALL = 250; +const LARGE = 800; +const REPS = 15; +const WARMUP = 5; +/** Two @Value properties plus one @ConfigurationProperties class per file. */ +const FACTS_PER_FILE = 3; + +function consumerSource(i, mode) { + const imports = + mode === 'wildcard' + ? `import org.springframework.beans.factory.annotation.* +import org.springframework.boot.context.properties.*` + : `import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.context.properties.ConfigurationProperties`; + + return `package bench.config +${imports} + +class Shadowing${i} { + class Value +} + +@ConfigurationProperties(prefix = "svc.${i}") +class Props${i} { + var endpoint: String? = null +} + +class Consumer${i} { + @Value("\\\${app.key${i}}") + var timeout: Int = 0 + + @Value("\\\${app.other${i}:5}") + var other: String? = null + + fun decoy() {} +} +`; +} + +/** Position-free fact identity, so both arms are directly comparable. */ +function factId(fact) { + const consumer = fact.consumer; + return consumer.kind === 'value' + ? `value|${consumer.fieldName}|${[...consumer.keys].sort().join(',')}` + : `configuration-properties|${consumer.className}|${consumer.prefix}`; +} + +function prepare(mode, fileCount) { + const files = []; + const lang = getLanguageGrammar(SupportedLanguages.Kotlin); + for (let i = 0; i < fileCount; i++) { + const parser = new Parser(); + parser.setLanguage(lang); + const filePath = `bench/${mode}/Consumer${i}.kt`; + files.push({ tree: parser.parse(consumerSource(i, mode)), filePath, parser }); + } + return files; +} + +function runAll(files) { + const ids = []; + for (const f of files) { + for (const fact of captureKotlinSpringConfigConsumerFacts(f.tree.rootNode, f.filePath)) { + ids.push(factId(fact)); + } + } + return ids; +} + +function measure(mode, fileCount) { + const files = prepare(mode, fileCount); + const { last, ms } = minSample(() => runAll(files), WARMUP, REPS); + return { + files: fileCount, + ms, + facts: last.length, + fingerprint: fingerprintIds(last), + }; +} + +function failIfNeeded(current, errors) { + if (errors.length === 0) return; + console.error(JSON.stringify({ report: current, errors }, null, 2)); + process.exit(1); +} + +function runFactCountCheck(current, expectedCounts) { + const errors = []; + for (const [arm, expected] of Object.entries(expectedCounts)) { + const actual = current[arm]?.facts; + if (actual !== expected) errors.push(`${arm}.facts ${String(actual)} != ${expected}`); + } + failIfNeeded(current, errors); +} + +/** + * A wildcard import plus a sibling `Value` declaration must capture exactly the + * facts the explicit-import control captures. + */ +function runFingerprintParityCheck(current, leftArm, rightArm) { + const left = current[leftArm]?.fingerprint; + const right = current[rightArm]?.fingerprint; + failIfNeeded( + current, + left === right ? [] : [`${leftArm}.fingerprint ${left} != ${rightArm}.fingerprint ${right}`], + ); +} + +const report = { + exact_small: measure('exact', SMALL), + exact_large: measure('exact', LARGE), + wildcard_small: measure('wildcard', SMALL), + wildcard_large: measure('wildcard', LARGE), +}; +report.scaling_ratio = Number( + (report.wildcard_large.ms / report.wildcard_small.ms / (LARGE / SMALL)).toFixed(3), +); +report.widening_overhead = Number( + (report.wildcard_large.ms / Math.max(report.exact_large.ms, 0.001)).toFixed(3), +); +report.fingerprint = report.wildcard_large.fingerprint; + +runFactCountCheck(report, { + exact_large: LARGE * FACTS_PER_FILE, + wildcard_large: LARGE * FACTS_PER_FILE, +}); +runFingerprintParityCheck(report, 'exact_large', 'wildcard_large'); + +if (!process.argv.includes('--check')) { + console.log(JSON.stringify(report, null, 2)); + process.exit(0); +} + +runBaselineCheck(report, BASELINE_PATH); diff --git a/gitnexus/bench/v8-sidecar/measure.mjs b/gitnexus/bench/v8-sidecar/measure.mjs new file mode 100644 index 000000000..68867b9ba --- /dev/null +++ b/gitnexus/bench/v8-sidecar/measure.mjs @@ -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 + */ +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 '); + 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 }); +} diff --git a/gitnexus/hooks/antigravity/gitnexus-antigravity-hook.cjs b/gitnexus/hooks/antigravity/gitnexus-antigravity-hook.cjs index 3331ae3fc..630195087 100755 --- a/gitnexus/hooks/antigravity/gitnexus-antigravity-hook.cjs +++ b/gitnexus/hooks/antigravity/gitnexus-antigravity-hook.cjs @@ -503,7 +503,7 @@ function buildStaleIndexHint(gitNexusDir, cwd) { if (currentHead === lastCommit) return ''; - const analyzeCmd = formatAnalyzeCommand({ embeddings: hadEmbeddings }); + const analyzeCmd = formatAnalyzeCommand({ embeddings: hadEmbeddings, indexOnly: true }); return ( `[GitNexus] index is stale (last indexed: ${lastCommit ? lastCommit.slice(0, 7) : 'never'}). ` + `Run \`${analyzeCmd}\` to refresh the knowledge graph.` diff --git a/gitnexus/hooks/claude/gitnexus-hook.cjs b/gitnexus/hooks/claude/gitnexus-hook.cjs index 18be614f4..1b75ed17d 100755 --- a/gitnexus/hooks/claude/gitnexus-hook.cjs +++ b/gitnexus/hooks/claude/gitnexus-hook.cjs @@ -523,7 +523,7 @@ function handlePostToolUse(input) { // If HEAD matches last indexed commit, no reindex needed if (currentHead && currentHead === lastCommit) return; - const analyzeCmd = formatAnalyzeCommand({ embeddings: hadEmbeddings }); + const analyzeCmd = formatAnalyzeCommand({ embeddings: hadEmbeddings, indexOnly: true }); sendHookResponse( 'PostToolUse', `GitNexus index is stale (last indexed: ${lastCommit ? lastCommit.slice(0, 7) : 'never'}). ` + diff --git a/gitnexus/hooks/claude/resolve-analyze-cmd.cjs b/gitnexus/hooks/claude/resolve-analyze-cmd.cjs index 56f5235fb..c74f03f5d 100644 --- a/gitnexus/hooks/claude/resolve-analyze-cmd.cjs +++ b/gitnexus/hooks/claude/resolve-analyze-cmd.cjs @@ -276,7 +276,13 @@ function formatBunxCommand(gitnexusArgs) { } function formatAnalyzeCommand(options = {}, deps = {}) { - const suffix = options.embeddings ? ' --embeddings' : ''; + // `--index-only` is what a routine "your index is stale" nudge wants: it + // reindexes without rewriting AGENTS.md / CLAUDE.md / skills, so an agent + // following the nudge on every commit cannot churn the tracked agent guides + // (#2907). Callers that actually want the docs refreshed omit it. + const suffix = `${options.indexOnly ? ' --index-only' : ''}${ + options.embeddings ? ' --embeddings' : '' + }`; // Keep the stale-index hook budget tight by querying each tool at most once. // The memoized `probe` is a spawn-free PATH scan (resolveOnPath) shared with // resolveInvocationMode, so `gitnexus` is scanned only once and no subprocess diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 53d0612ec..e94a72426 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -1,12 +1,12 @@ { "name": "gitnexus", - "version": "1.6.9", + "version": "1.6.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitnexus", - "version": "1.6.9", + "version": "1.6.10", "hasInstallScript": true, "license": "PolyForm-Noncommercial-1.0.0", "dependencies": { @@ -14,15 +14,18 @@ "@modelcontextprotocol/sdk": "^1.0.0", "@scarf/scarf": "^1.4.0", "busboy": "^1.6.0", + "chokidar": "^5.0.0", "cli-progress": "^3.12.0", "commander": "^15.0.0", "cors": "^2.8.5", "express": "^5.2.1", "express-rate-limit": "^8.4.1", + "fast-xml-parser": "^5.11.1", "glob": "^13.0.6", "graphology": "^0.26.0", "graphology-indices": "^0.17.0", "graphology-utils": "^2.3.0", + "graphql": "^17.0.2", "ignore": "^7.0.5", "js-yaml": "^5.0.0", "jsonc-parser": "^3.3.1", @@ -79,7 +82,7 @@ "version": "1.0.0", "dev": true, "devDependencies": { - "typescript": "^6.0.3" + "typescript": "^7.0.2" } }, "node_modules/@babel/code-frame": { @@ -218,33 +221,10 @@ "node": ">=18" } }, - "node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/runtime": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", - "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", - "dev": true, + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", "license": "MIT", "optional": true, "dependencies": { @@ -747,9 +727,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", "cpu": [ "arm64" ], @@ -759,19 +739,19 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" + "@img/sharp-libvips-darwin-arm64": "1.3.2" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", "cpu": [ "x64" ], @@ -781,19 +761,38 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" + "@img/sharp-libvips-darwin-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", "cpu": [ "arm64" ], @@ -807,9 +806,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", "cpu": [ "x64" ], @@ -823,9 +822,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", "cpu": [ "arm" ], @@ -839,9 +838,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", "cpu": [ "arm64" ], @@ -855,9 +854,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", "cpu": [ "ppc64" ], @@ -871,9 +870,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", "cpu": [ "riscv64" ], @@ -887,9 +886,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", "cpu": [ "s390x" ], @@ -903,9 +902,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", "cpu": [ "x64" ], @@ -919,9 +918,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", "cpu": [ "arm64" ], @@ -935,9 +934,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", "cpu": [ "x64" ], @@ -951,9 +950,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", "cpu": [ "arm" ], @@ -963,19 +962,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" + "@img/sharp-libvips-linux-arm": "1.3.2" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", "cpu": [ "arm64" ], @@ -985,19 +984,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" + "@img/sharp-libvips-linux-arm64": "1.3.2" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", "cpu": [ "ppc64" ], @@ -1007,19 +1006,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" + "@img/sharp-libvips-linux-ppc64": "1.3.2" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", "cpu": [ "riscv64" ], @@ -1029,19 +1028,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" + "@img/sharp-libvips-linux-riscv64": "1.3.2" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", "cpu": [ "s390x" ], @@ -1051,19 +1050,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" + "@img/sharp-libvips-linux-s390x": "1.3.2" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", "cpu": [ "x64" ], @@ -1073,19 +1072,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" + "@img/sharp-libvips-linux-x64": "1.3.2" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", "cpu": [ "arm64" ], @@ -1095,19 +1094,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", "cpu": [ "x64" ], @@ -1117,38 +1116,54 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", - "cpu": [ - "wasm32" - ], + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.7.0" + "@emnapi/runtime": "^1.11.1" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", "cpu": [ "arm64" ], @@ -1158,16 +1173,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", "cpu": [ "ia32" ], @@ -1177,16 +1192,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": "^20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", "cpu": [ "x64" ], @@ -1196,7 +1211,7 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" @@ -1254,9 +1269,9 @@ } }, "node_modules/@ladybugdb/core": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/@ladybugdb/core/-/core-0.19.0.tgz", - "integrity": "sha512-vlE2D2b6Ej/OiwtBCRtye34j8uRH9aV/ziJM+ZnXow77VkVr8zeVjSrAEj/eisj+uPPQ/pmuOXzJjJra0m0Bbg==", + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@ladybugdb/core/-/core-0.19.1.tgz", + "integrity": "sha512-8W2g6xUi4jm96fs4EayyMcsvEEtIb8vboZhw9/YG98881cIcmZjmqAN91XGUp4vb8NqoFr3Wp7wcu3dqJk0b7w==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -1265,17 +1280,17 @@ "node-addon-api": "^6.0.0" }, "optionalDependencies": { - "@ladybugdb/core-darwin-arm64": "0.19.0", - "@ladybugdb/core-darwin-x64": "0.19.0", - "@ladybugdb/core-linux-arm64": "0.19.0", - "@ladybugdb/core-linux-x64": "0.19.0", - "@ladybugdb/core-win32-x64": "0.19.0" + "@ladybugdb/core-darwin-arm64": "0.19.1", + "@ladybugdb/core-darwin-x64": "0.19.1", + "@ladybugdb/core-linux-arm64": "0.19.1", + "@ladybugdb/core-linux-x64": "0.19.1", + "@ladybugdb/core-win32-x64": "0.19.1" } }, "node_modules/@ladybugdb/core-darwin-arm64": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-arm64/-/core-darwin-arm64-0.19.0.tgz", - "integrity": "sha512-3Ut3XL9kowzBoHw0wrN3QnW4xy5wYoPBypeuMtH92j59fol2w9e3lQJ6DM29YJ4F6mAVfW2cYGBXH2l9aXGmmw==", + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-arm64/-/core-darwin-arm64-0.19.1.tgz", + "integrity": "sha512-VGQs1NThAygMsoOlxud05pqKA9xfUptl55iYkwvW45As5MSI7+M86WN0Pp0VdPEfw8vNQJehrlHR5LvVAuWc2Q==", "cpu": [ "arm64" ], @@ -1286,9 +1301,9 @@ ] }, "node_modules/@ladybugdb/core-darwin-x64": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-x64/-/core-darwin-x64-0.19.0.tgz", - "integrity": "sha512-KHuCBx+jkyxdfFEmmbsfUS1f108P4rS+VNkwCrMLvzJmJOZTZgNKeRmT0vO7qRum5KEoHSIPJLj2Le//rCYigQ==", + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-x64/-/core-darwin-x64-0.19.1.tgz", + "integrity": "sha512-CGfM6ostxDS5jztxwjkXXtxrjMDgsFMRoyr5HZDCFw1+iXC1rIzmK/Y7RIw+KbQ49aPzSmkhBC447mFviBJxoA==", "cpu": [ "x64" ], @@ -1299,9 +1314,9 @@ ] }, "node_modules/@ladybugdb/core-linux-arm64": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-arm64/-/core-linux-arm64-0.19.0.tgz", - "integrity": "sha512-z4Z67LZlgj6H7YnKMB1PornbdmeszVxfENusfDQ2MFyOyrze1X6c/3MkhVPyunuaTtriN9SBnEdyK39mB7NdyQ==", + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-arm64/-/core-linux-arm64-0.19.1.tgz", + "integrity": "sha512-BZUQwlkvNXENc5GVyXdfRF0Dv9JX8XMlcdMMiB5GKrEhTCpajQ3D58woHPVvn0JEjw7Ms3tHo6kXUAMZKYXIVg==", "cpu": [ "arm64" ], @@ -1312,9 +1327,9 @@ ] }, "node_modules/@ladybugdb/core-linux-x64": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-x64/-/core-linux-x64-0.19.0.tgz", - "integrity": "sha512-aJOh7+XbTzCLNloK+KlDXuRmHgr7nLqyQwR/BR8fP/XsWVnxCKGpVVL8s+Lms7JNQAh6vEsvExttGgUq9hAu1Q==", + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-x64/-/core-linux-x64-0.19.1.tgz", + "integrity": "sha512-LDx+E1UHlmNXSb3F9QmvdBgZGfB3wI/DcrHzfOwXgT3BP8C4ScB2tZdpiYQiuPp8MiSZ9kuuGqos8A4tQKQu8Q==", "cpu": [ "x64" ], @@ -1325,9 +1340,9 @@ ] }, "node_modules/@ladybugdb/core-win32-x64": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/@ladybugdb/core-win32-x64/-/core-win32-x64-0.19.0.tgz", - "integrity": "sha512-y2/IOMKmydo4ZfQPDZuZhiFC104VJQ9lwc0w1KVdvb4Z2RIUUL8/tn5QD4Uq8DUrJGxQhoEESPnlkk69cKaaDQ==", + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@ladybugdb/core-win32-x64/-/core-win32-x64-0.19.1.tgz", + "integrity": "sha512-2spst1g+Z050Fz/5z7Pc6Fuc5dVXLzekOuWW4lP+mEGCL3tkv3QWYxk37DiFy+O9fDFxiWK2f3aauab58/f9kQ==", "cpu": [ "x64" ], @@ -1383,29 +1398,22 @@ } } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", - "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.3" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } + "node_modules/@nodable/entities": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz", + "integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" }, "node_modules/@oxc-project/types": { - "version": "0.139.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", - "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "version": "0.144.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz", + "integrity": "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==", "dev": true, "license": "MIT", "funding": { @@ -1485,9 +1493,9 @@ "optional": true }, "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", - "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz", + "integrity": "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==", "cpu": [ "arm64" ], @@ -1502,9 +1510,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", - "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==", "cpu": [ "arm64" ], @@ -1519,9 +1527,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", - "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.4.tgz", + "integrity": "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==", "cpu": [ "x64" ], @@ -1536,9 +1544,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", - "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.4.tgz", + "integrity": "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==", "cpu": [ "x64" ], @@ -1553,9 +1561,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", - "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.4.tgz", + "integrity": "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==", "cpu": [ "arm" ], @@ -1570,16 +1578,13 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", - "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.4.tgz", + "integrity": "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==", "cpu": [ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1590,16 +1595,13 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", - "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.4.tgz", + "integrity": "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==", "cpu": [ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1610,16 +1612,13 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", - "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.4.tgz", + "integrity": "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==", "cpu": [ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1630,16 +1629,13 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", - "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.4.tgz", + "integrity": "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==", "cpu": [ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1650,16 +1646,13 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", - "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.4.tgz", + "integrity": "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==", "cpu": [ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1670,16 +1663,13 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", - "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.4.tgz", + "integrity": "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==", "cpu": [ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1690,9 +1680,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", - "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.4.tgz", + "integrity": "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==", "cpu": [ "arm64" ], @@ -1706,40 +1696,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", - "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.11.1", - "@emnapi/runtime": "1.11.1", - "@napi-rs/wasm-runtime": "^1.1.6" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", - "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.4.tgz", + "integrity": "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==", "cpu": [ "arm64" ], @@ -1754,9 +1714,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", - "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.4.tgz", + "integrity": "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==", "cpu": [ "x64" ], @@ -1800,17 +1760,6 @@ "tslib": "^2.8.0" } }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", - "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -1939,9 +1888,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.1.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz", - "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==", + "version": "26.3.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.3.0.tgz", + "integrity": "sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw==", "devOptional": true, "license": "MIT", "dependencies": { @@ -1994,14 +1943,14 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", - "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz", + "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.10", + "@vitest/utils": "4.1.11", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -2015,8 +1964,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.10", - "vitest": "4.1.10" + "@vitest/browser": "4.1.11", + "vitest": "4.1.11" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -2025,16 +1974,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", - "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -2043,13 +1992,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", - "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.10", + "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -2070,9 +2019,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", - "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "dev": true, "license": "MIT", "dependencies": { @@ -2083,13 +2032,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", - "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.10", + "@vitest/utils": "4.1.11", "pathe": "^2.0.3" }, "funding": { @@ -2097,14 +2046,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", - "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -2113,9 +2062,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", - "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "dev": true, "license": "MIT", "funding": { @@ -2123,13 +2072,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", - "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.10", + "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -2151,13 +2100,13 @@ } }, "node_modules/adm-zip": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", - "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.0.tgz", + "integrity": "sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg==", "license": "MIT", "optional": true, "engines": { - "node": ">=12.0" + "node": ">=14.0" } }, "node_modules/ajv": { @@ -2217,6 +2166,18 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/apache-arrow": { "version": "21.1.0", "resolved": "https://registry.npmjs.org/apache-arrow/-/apache-arrow-21.1.0.tgz", @@ -2446,6 +2407,21 @@ "url": "https://github.com/chalk/chalk-template?sponsor=1" } }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -3070,6 +3046,45 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fast-xml-builder": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz", + "integrity": "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.6.2", + "xml-naming": "^0.3.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.11.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.11.1.tgz", + "integrity": "sha512-TBw6K/fxoQGGjCmZDw9w/ZwP3uDcnTM4YH/g+PFRWr8sbe5idXtxNN6vITh4+1ruCZaho6uBFurElsA7F0zzgw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^3.0.0", + "fast-xml-builder": "^1.2.0", + "is-unsafe": "^2.0.0", + "path-expression-matcher": "^1.6.2", + "strnum": "^2.4.2", + "xml-naming": "^0.3.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -3356,6 +3371,15 @@ "graphology-types": ">=0.23.0" } }, + "node_modules/graphql": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-17.0.2.tgz", + "integrity": "sha512-FRWbddMxfkjiB7z+aQDWIR+E34xo9I8c9mtK2RPv8PmMzKRvrdsreHL/Ui/TmwHJfhHChEtsFPyMHKI+xuarQQ==", + "license": "MIT", + "engines": { + "node": "^22.0.0 || ^24.0.0 || ^25.0.0 || >=26.0.0" + } + }, "node_modules/guid-typescript": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz", @@ -3521,6 +3545,18 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/is-unsafe": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.2.tgz", + "integrity": "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/isexe": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", @@ -3595,9 +3631,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.3.tgz", - "integrity": "sha512-n+mUVyUX5bVv7G/G2zyIHOhdxfuU1dY2NOFzTQUWiMUbFss8b57NFlgCCaggU78wSw5KVS9cllzeLyzyR+n5nw==", + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.4.0.tgz", + "integrity": "sha512-jE7vUJIebKzYQI5xu4co5CRBDlDEYnHrdzsxs4O2giCz4v2SbVMYKpmt1D9L38OKQAeCWmrOTRiCV93u0UkaJA==", "funding": [ { "type": "github", @@ -3668,9 +3704,9 @@ } }, "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -3684,23 +3720,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", "cpu": [ "arm64" ], @@ -3719,9 +3755,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", "cpu": [ "arm64" ], @@ -3740,9 +3776,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", "cpu": [ "x64" ], @@ -3761,9 +3797,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", "cpu": [ "x64" ], @@ -3782,9 +3818,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", "cpu": [ "arm" ], @@ -3803,16 +3839,13 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", "cpu": [ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3827,16 +3860,13 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", "cpu": [ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3851,16 +3881,13 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", "cpu": [ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3875,16 +3902,13 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", "cpu": [ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3899,9 +3923,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", "cpu": [ "arm64" ], @@ -3920,9 +3944,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", "cpu": [ "x64" ], @@ -4182,9 +4206,9 @@ "license": "MIT" }, "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==", "dev": true, "funding": [ { @@ -4210,9 +4234,9 @@ } }, "node_modules/node-addon-api": { - "version": "8.9.1", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.1.tgz", - "integrity": "sha512-4eUQWVPCUUUiBjLnHS3cXWeC6ryoPUc0U3rP7IuzapoGbzMqd/r6KKO0clr0b+snQhsrueFEhCZDdK+LK7hxKg==", + "version": "8.9.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz", + "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==", "license": "MIT", "engines": { "node": "^18 || ^20 || >= 21" @@ -4314,15 +4338,15 @@ } }, "node_modules/onnxruntime-common": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.27.0.tgz", - "integrity": "sha512-3KxL5wIVqa8Ex08jxSzncm9CMgw8CjOFyOQ7SxvG9o0cVLlhTNKXyIQuTbtX4tGPJEf73OER2xrjt4HJSBL4ow==", + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.29.0.tgz", + "integrity": "sha512-/F63/e2VJoaVXGGNu6S5QH7jivBThGO95OzAVXXQ8hTta/b1QxI8udHa6cI3+3mAb5WWIIaMMwfZw01oivjJ1g==", "license": "MIT" }, "node_modules/onnxruntime-node": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.27.0.tgz", - "integrity": "sha512-QEzGwrvNBgv4uPVdnbHsOGG4G6T96mdlcFI8aAKPjMU8wOPpVocPXb6k3QGkaZagVTv2G9Bnnbo6Z3JdXr1fQw==", + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.29.0.tgz", + "integrity": "sha512-WjiVVB72riILz8HbYvxvmjKyE/WmkYoSfKY++axo5jAR609HQg8MwiG/HhShpTcJfmmAdzxxmB+MMST3A+SiPA==", "hasInstallScript": true, "license": "MIT", "optional": true, @@ -4332,9 +4356,9 @@ "linux" ], "dependencies": { - "adm-zip": "^0.5.16", + "adm-zip": "^0.6.0", "global-agent": "^4.1.3", - "onnxruntime-common": "1.27.0" + "onnxruntime-common": "1.29.0" } }, "node_modules/onnxruntime-web": { @@ -4386,6 +4410,21 @@ "node": ">= 0.8" } }, + "node_modules/path-expression-matcher": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -4528,9 +4567,9 @@ "optional": true }, "node_modules/postcss": { - "version": "8.5.23", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", - "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "dev": true, "funding": [ { @@ -4548,7 +4587,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.16", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -4573,9 +4612,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, @@ -4679,6 +4718,19 @@ "rc": "cli.js" } }, + "node_modules/readdirp": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", + "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/real-require": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", @@ -4707,13 +4759,13 @@ } }, "node_modules/rolldown": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", - "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz", + "integrity": "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.139.0", + "@oxc-project/types": "=0.144.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -4723,21 +4775,20 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.1.5", - "@rolldown/binding-darwin-arm64": "1.1.5", - "@rolldown/binding-darwin-x64": "1.1.5", - "@rolldown/binding-freebsd-x64": "1.1.5", - "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", - "@rolldown/binding-linux-arm64-gnu": "1.1.5", - "@rolldown/binding-linux-arm64-musl": "1.1.5", - "@rolldown/binding-linux-ppc64-gnu": "1.1.5", - "@rolldown/binding-linux-s390x-gnu": "1.1.5", - "@rolldown/binding-linux-x64-gnu": "1.1.5", - "@rolldown/binding-linux-x64-musl": "1.1.5", - "@rolldown/binding-openharmony-arm64": "1.1.5", - "@rolldown/binding-wasm32-wasi": "1.1.5", - "@rolldown/binding-win32-arm64-msvc": "1.1.5", - "@rolldown/binding-win32-x64-msvc": "1.1.5" + "@rolldown/binding-android-arm64": "1.2.4", + "@rolldown/binding-darwin-arm64": "1.2.4", + "@rolldown/binding-darwin-x64": "1.2.4", + "@rolldown/binding-freebsd-x64": "1.2.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.4", + "@rolldown/binding-linux-arm64-gnu": "1.2.4", + "@rolldown/binding-linux-arm64-musl": "1.2.4", + "@rolldown/binding-linux-ppc64-gnu": "1.2.4", + "@rolldown/binding-linux-s390x-gnu": "1.2.4", + "@rolldown/binding-linux-x64-gnu": "1.2.4", + "@rolldown/binding-linux-x64-musl": "1.2.4", + "@rolldown/binding-openharmony-arm64": "1.2.4", + "@rolldown/binding-win32-arm64-msvc": "1.2.4", + "@rolldown/binding-win32-x64-msvc": "1.2.4" } }, "node_modules/router": { @@ -4798,9 +4849,9 @@ "license": "BSD-3-Clause" }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -4877,48 +4928,53 @@ "license": "ISC" }, "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", - "hasInstallScript": true, + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/colour": "^1.0.0", + "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", - "semver": "^7.7.3" + "semver": "^7.8.5" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/shebang-command": { @@ -5115,6 +5171,21 @@ "node": ">=0.10.0" } }, + "node_modules/strnum": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.2.tgz", + "integrity": "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "anynum": "^1.0.1" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -5430,9 +5501,9 @@ "license": "0BSD" }, "node_modules/tsx": { - "version": "4.23.5", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.5.tgz", - "integrity": "sha512-rw55FUaqOoI7RvlQwLbhO4nSDApnQ4/CykPuiQ/EPvtrX3WA9Ig55jIt9VvbBJbzJuj12ueRu4PMZ2SxPVbihg==", + "version": "4.23.12", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz", + "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", "dev": true, "license": "MIT", "dependencies": { @@ -5547,9 +5618,9 @@ "license": "MIT" }, "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" @@ -5569,16 +5640,16 @@ } }, "node_modules/vite": { - "version": "8.1.4", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", - "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", "dev": true, "license": "MIT", "dependencies": { - "lightningcss": "^1.32.0", + "lightningcss": "^1.33.0", "picomatch": "^4.0.5", - "postcss": "^8.5.16", - "rolldown": "~1.1.4", + "postcss": "^8.5.25", + "rolldown": "~1.2.1", "tinyglobby": "^0.2.17" }, "bin": { @@ -5595,7 +5666,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.3.0", + "@vitejs/devtools": "^0.4.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -5647,19 +5718,19 @@ } }, "node_modules/vitest": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", - "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.10", - "@vitest/mocker": "4.1.10", - "@vitest/pretty-format": "4.1.10", - "@vitest/runner": "4.1.10", - "@vitest/snapshot": "4.1.10", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -5687,12 +5758,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.10", - "@vitest/browser-preview": "4.1.10", - "@vitest/browser-webdriverio": "4.1.10", - "@vitest/coverage-istanbul": "4.1.10", - "@vitest/coverage-v8": "4.1.10", - "@vitest/ui": "4.1.10", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -5800,6 +5871,21 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/xml-naming": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", + "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/gitnexus/package.json b/gitnexus/package.json index 5315226f1..1e15ad9b4 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -1,6 +1,6 @@ { "name": "gitnexus", - "version": "1.6.9", + "version": "1.6.10", "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.", "author": "Abhigyan Patwari", "license": "PolyForm-Noncommercial-1.0.0", @@ -60,15 +60,18 @@ "@modelcontextprotocol/sdk": "^1.0.0", "@scarf/scarf": "^1.4.0", "busboy": "^1.6.0", + "chokidar": "^5.0.0", "cli-progress": "^3.12.0", "commander": "^15.0.0", "cors": "^2.8.5", "express": "^5.2.1", "express-rate-limit": "^8.4.1", + "fast-xml-parser": "^5.11.1", "glob": "^13.0.6", "graphology": "^0.26.0", "graphology-indices": "^0.17.0", "graphology-utils": "^2.3.0", + "graphql": "^17.0.2", "ignore": "^7.0.5", "js-yaml": "^5.0.0", "jsonc-parser": "^3.3.1", @@ -120,6 +123,8 @@ "vitest": "^4.0.18" }, "overrides": { + "adm-zip": ">=0.6.0", + "sharp": ">=0.35.0", "@huggingface/transformers": { "onnxruntime-node": "$onnxruntime-node" } diff --git a/gitnexus/scripts/cross-platform-shard.ts b/gitnexus/scripts/cross-platform-shard.ts index 1a026a5e0..2c52dd86b 100644 --- a/gitnexus/scripts/cross-platform-shard.ts +++ b/gitnexus/scripts/cross-platform-shard.ts @@ -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> = { - 'test/integration/cli-e2e.test.ts': 361, + // Re-measured after the analyze --watch e2e landed in #3072. The previous + // 361 s entry undercharged this suite and left shard 1 close to the watchdog. + 'test/integration/cli-e2e.test.ts': 621, 'test/integration/worker-pool.test.ts': 222, 'test/unit/incremental-vector-extension-ordering.test.ts': 87, // ESTIMATE, not a measurement (#2841): this suite drives more full @@ -65,6 +67,16 @@ export const WINDOWS_WEIGHTS_SEC: Readonly> = { 'test/integration/antigravity-hook-e2e.test.ts': 7, 'test/unit/index-lock.test.ts': 5, 'test/unit/setup.test.ts': 5, + // ESTIMATE, not a measurement. This file asserts almost nothing; it READS — + // one 4893-file pass over every tracked text file, plus an 830-file pass over + // `src/`. Measured at 2.3 s and 0.3 s per pass on a virtualised and a local + // Linux filesystem respectively, so the cost is entirely per-file open + // latency, which is the term Windows inflates most (NTFS plus Defender on + // every read). Scaled from the slower Linux figure to keep the split + // conservative rather than let the 8 s PER_FILE_OVERHEAD floor under-charge + // a file that touches more paths than anything else here. Replace with a real + // figure after the first green Windows matrix run. + 'test/unit/source-control-bytes.test.ts': 15, }; /** diff --git a/gitnexus/scripts/cross-platform-tests.ts b/gitnexus/scripts/cross-platform-tests.ts index 69383d355..44e45fbbb 100644 --- a/gitnexus/scripts/cross-platform-tests.ts +++ b/gitnexus/scripts/cross-platform-tests.ts @@ -36,6 +36,16 @@ const PLATFORM_LOGIC = [ // must exercise the Windows backslash branch, so run it on the OS matrix (#2394). 'test/unit/cli-entry.test.ts', 'test/unit/platform-capabilities.test.ts', + // The gitnexus-plan safe writer resolves every name through a per-platform + // backend: Linux anchors through /proc/self/fd, macOS resolves lexically and + // verifies each step against descriptors it holds open. Publication is link(2) + // on both. #2905 shipped the Darwin backend after the suite had silently + // skipped on every non-Linux runner, so this file must run on the OS matrix or + // the macOS half is unverified by construction — and the flag, trailing- + // separator and hard-link fixtures assert kernel behaviour that only a real + // Darwin kernel can confirm. Windows is refused by the capability gate; the + // suite asserts that refusal rather than skipping it. + 'test/unit/evidence-provenance-helper.test.ts', // Windows drive-letter case variance in the analyzer runner-identity path // fields (#2668): normalizeAnalyzerRootPath is a POSIX no-op, so the // "identity path fields are normalizer-stable" fixpoint guard only bites on @@ -80,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 @@ -103,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. @@ -130,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', @@ -198,6 +213,18 @@ const SPAWN_CLI = [ // exposed a file-backend double-admit race here (#2658 review); the reclaim is // now judgment-verified so a live holder is never displaced. 'test/integration/analyze-index-lock-concurrency.test.ts', + // The per-group sync lock (R9), same class of guarantee one level up: real + // child processes contend for one group's lock while this process runs a real + // `syncGroup`, and the CLI case spawns the real command. Everything that + // varies here is platform-owned — which backend `selectBackend()` picks + // (Windows named pipe / Linux abstract socket / macOS file lock), kernel + // auto-release on SIGKILL vs. the file backend's pid-liveness reclaim, and + // `mkdir` over an occupied path. The fail-closed cases pin + // GITNEXUS_INDEX_LOCK_BACKEND=file so the filesystem branch is exercised on + // every OS rather than only where it is the default; no case is skipped on + // any platform, because a skipped case turns "a sync that cannot be protected + // does not run" into a claim that holds on Ubuntu only. + 'test/integration/group/group-sync-lock-concurrency.test.ts', // The three `dist/` module-load closure guards, all built on the shared // child-process probe in `test/helpers/module-load-probe.ts`. That probe IS // the platform-varying part: it spawns `process.execPath` in array form, @@ -210,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. @@ -249,8 +276,31 @@ const NATIVE_ADDON_SMOKE = [ // platforms (CRLF, symlinks, permissions, temp dirs) const FILESYSTEM = [ 'test/integration/filesystem-walker.test.ts', + 'test/integration/watch-filesystem.test.ts', 'test/integration/markdown-processor-crlf.test.ts', 'test/integration/ignore-and-skip-e2e.test.ts', + // Pins that the bridge pairing verdict is measured before the database is + // opened. The property it protects is about mtime behavior across OS and + // filesystem, and the alternative — really opening the bridge — cannot run on + // Windows at all (in-process write→read reopen of the same bridge.lbug is a + // documented limitation). Running it on every platform is the whole point: + // Windows is where an unverified assumption about mtime would hurt most. + 'test/unit/group/bridge-pairing-precedes-open.test.ts', + // The raw-control-byte guard reads every tracked text file `git ls-files` + // reports — 4893 of them — and decides membership from the git path, which is + // always `/`-separated no matter what the host separator is. Both halves of + // that are platform-varying: the collector basename-matches with + // `path.posix.basename` against `git ls-files -z` output while the reads go + // through `path.join`, so on Windows the same string is consumed under two + // separator conventions in one pass, and only a real windows-latest run + // proves they agree. It is also the file-count-heaviest read loop in the + // suite, so it is where a per-file filesystem cost (NTFS + Defender, or + // macOS's slower stat path) would show up first. No case is skipped on any + // platform: a guard that only holds on Ubuntu is not a guard on the file + // whose NUL it exists to catch. Budget: the heaviest single case is one + // 4893-file pass — 2.3 s on a slow virtualised filesystem, 0.34 s on a local + // disk — against a 30 s testTimeout. + 'test/unit/source-control-bytes.test.ts', ]; const ALL_CROSS_PLATFORM = [ diff --git a/gitnexus/skills/gitnexus-cli.md b/gitnexus/skills/gitnexus-cli.md index 342e8b08f..be02d92fd 100644 --- a/gitnexus/skills/gitnexus-cli.md +++ b/gitnexus/skills/gitnexus-cli.md @@ -21,13 +21,20 @@ Run from the project root. This parses all source files, builds the knowledge gr | Flag | Effect | | -------------- | ---------------------------------------------------------------- | +| `--watch` | Keep a Git repository index current with serialized refreshes | +| `--debounce ` | Watch quiet period before refresh (default: 300 ms) | | `--force` | Force full re-index even if up to date | | `--embeddings` | Enable embedding generation for semantic search (off by default) | | `--drop-embeddings` | Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` preserves them. | | `--pdg` | Build the program-dependence layers used by `explain` and `pdg_query` (taint, CDG, and REACHING_DEF). | +| `--spring-actuator ` | Import opt-in Spring Boot Actuator mappings, beans, conditions, configprops, and env snapshots. Forces a full rebuild; unsupported with `--watch`. | **When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook detects staleness after `git commit` and `git merge` and notifies the agent to run `analyze` — the hook does not run analyze itself, to avoid blocking the agent for up to 120s and risking KuzuDB corruption on timeout. +For Spring runtime enrichment, pass a JSON bundle, one endpoint JSON file, or a directory containing endpoint files. Route evidence is authoritative only when `runtimeConfirmed === true`; `runtimeSource` records provenance and may also accompany `handler-conflict`. Env/configprops values are never persisted. + +Use `node .gitnexus/run.cjs analyze --watch` for a long-lived local Git repository. It performs an initial analysis, queues scanner-admitted file changes, and retries intact failed batches with bounded backoff. Watch refreshes update only the graph: they skip AGENTS.md / CLAUDE.md injection and standard skill installation, so run a one-shot `analyze` when those generated files need updating. Watch rejects one-shot or context-output flags including `--force`, embedding flags, `--skills`, `--default-branch`, `--skip-agents-md`, `--skip-skills`, `--no-stats`, `--self-commit`, `--index-only`, and `--skip-git`. It never pulls remotes. Scheduled remote clone/pull is a different command: `gitnexus auto-sync`. Bare `gitnexus watch` is reserved and does not start either job. Running MCP and `serve` processes periodically check for a published replacement and reopen it without a restart. MCP checks are throttled to once every five seconds, so a tool call before the next check can briefly use the previous index. + ### status — Check index freshness ```bash @@ -55,15 +62,19 @@ Deletes the `.gitnexus/` directory and unregisters the repo from the global regi node .gitnexus/run.cjs wiki ``` -Generates repository documentation from the knowledge graph using an LLM. Requires an API key (saved to `~/.gitnexus/config.json` on first use). +Generates repository documentation from the knowledge graph using an LLM. HTTP providers require an API key (saved to `~/.gitnexus/config.json` on first use). Local CLI providers (`--provider cursor|claude|codex|opencode|grok`) use your existing CLI login. | Flag | Effect | | ------------------- | ----------------------------------------- | -| `--force` | Force full regeneration | -| `--model ` | LLM model (default: minimax/minimax-m2.5) | +| `--force` | Force full regeneration, also required to re-generate an existing wiki in a different language | +| `--provider ` | 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 ` | LLM model (default: MiniMax-M3) | | `--base-url ` | LLM API base URL | | `--api-key ` | LLM API key | | `--concurrency ` | Parallel LLM calls (default: 3) | +| `--timeout ` | LLM request timeout in seconds (default: disabled) | +| `--retries ` | Max LLM retry attempts per request (default: 3) | +| `--lang ` | Output language for generated documentation (e.g. english, chinese, spanish, japanese) | | `--gist` | Publish wiki as a public GitHub Gist | ### list — Show all indexed repos @@ -82,5 +93,5 @@ Lists all repositories registered in `~/.gitnexus/registry.json`. The MCP `list_ ## Troubleshooting - **"Not inside a git repository"**: Run from a directory inside a git repo -- **Index is stale after re-analyzing**: Restart Claude Code to reload the MCP server +- **Index is stale after re-analyzing**: Wait for the next MCP tool call to reopen the published index; this normally takes no more than five seconds - **Embeddings slow**: Omit `--embeddings` (it's off by default) or set `OPENAI_API_KEY` for faster API-based embedding diff --git a/gitnexus/skills/gitnexus-debugging.md b/gitnexus/skills/gitnexus-debugging.md index 4a33e589a..41fb568f8 100644 --- a/gitnexus/skills/gitnexus-debugging.md +++ b/gitnexus/skills/gitnexus-debugging.md @@ -13,9 +13,28 @@ description: "Use when the user is debugging a bug, tracing an error, or asking - "This endpoint returns 500" - Investigating bugs, errors, or unexpected behavior +## Bind the repository first + +A root cause traced in the wrong repository is a wrong root cause. + +Call `list_repos {}` before the first tool call. With one indexed repository, +use the examples below as written. With more than one, pass `repo` on every +call: an omitted `repo` normally errors, but under an MCP policy with a +configured default it resolves to that default silently. If you cannot tell +which repository is meant, stop and ask. This matters most for `cypher`, whose +statement carries no in-band hint of which database it ran against. + +`list_repos` is paginated, so page with `offset: pagination.nextOffset` until +`hasMore` is false before concluding a repository is absent. + +A stale index describes the code from before your bug, so refresh before +trusting a trace, and state the repository and index freshness with the +diagnosis. + ## Workflow ``` +0. list_repos {} → Bind repo 1. query({search_query: ""}) → Find related execution flows 2. context({name: ""}) → See callers/callees/processes 3. READ gitnexus://repo/{name}/process/{name} → Trace execution flow @@ -27,6 +46,7 @@ description: "Use when the user is debugging a bug, tracing an error, or asking ## Checklist ``` +- [ ] list_repos {} — bind repo; explicit repo when >1 indexed, ask if ambiguous - [ ] Understand the symptom (error message, unexpected behavior) - [ ] query for error text or related code - [ ] Identify the suspect function from returned processes @@ -34,6 +54,7 @@ description: "Use when the user is debugging a bug, tracing an error, or asking - [ ] Trace execution flow via process resource if applicable - [ ] cypher for custom call chain traces if needed - [ ] Read source files to confirm root cause +- [ ] State the repository and index freshness with the diagnosis ``` ## Debugging Patterns @@ -44,7 +65,7 @@ description: "Use when the user is debugging a bug, tracing an error, or asking | Wrong return value | `context` on the function → trace callees for data flow | | Intermittent failure | `context` → look for external calls, async deps | | Performance issue | `context` → find symbols with many callers (hot paths) | -| Recent regression | `detect_changes` to see what your changes affect | +| Recent regression | `detect_changes` to see what your changes affect — pass `worktree` for a linked worktree | | "How does A reach B?" | `trace` between the two symbols — shortest call chain in one call | ## Tools @@ -52,7 +73,7 @@ description: "Use when the user is debugging a bug, tracing an error, or asking **query** — find code related to error: ``` -query({search_query: "payment validation error"}) +query({search_query: "payment validation error", repo: "my-app"}) → Processes: CheckoutFlow, ErrorHandling → Symbols: validatePayment, handlePaymentError, PaymentException ``` @@ -60,13 +81,15 @@ query({search_query: "payment validation error"}) **context** — full context for a suspect: ``` -context({name: "validatePayment"}) +context({name: "validatePayment", repo: "my-app"}) → Incoming calls: processCheckout, webhookHandler → Outgoing calls: verifyCard, fetchRates (external API!) → Processes: CheckoutFlow (step 3/7) ``` -**cypher** — custom call chain traces: +**cypher** — custom call chain traces. Pass `repo` alongside the statement; the +Cypher text itself names no repository, so the result is unattributable without +it: ```cypher MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Function {name: "validatePayment"}) @@ -76,7 +99,7 @@ RETURN [n IN nodes(path) | n.name] AS chain **trace** — shortest call chain between two symbols ("how does A reach B?"), one call instead of chaining `context` hops: ``` -trace({ from: "processCheckout", to: "fetchRates" }) +trace({ from: "processCheckout", to: "fetchRates", repo: "my-app" }) → status: ok, hopCount: 3 → hops: processCheckout → validatePayment → verifyCard → fetchRates → edges: CALLS (1.0), CALLS (0.95), CALLS (1.0) @@ -87,15 +110,22 @@ When no path exists, `trace` reports the furthest reachable node — exactly whe ## Example: "Payment endpoint returns 500 intermittently" ``` -1. query({search_query: "payment error handling"}) +0. list_repos {} + → total: 2 (my-app, billing-api) — bind my-app explicitly on every call + +1. query({search_query: "payment error handling", repo: "my-app"}) → Processes: CheckoutFlow, ErrorHandling → Symbols: validatePayment, handlePaymentError -2. context({name: "validatePayment"}) +2. context({name: "validatePayment", repo: "my-app"}) → Outgoing calls: verifyCard, fetchRates (external API!) 3. READ gitnexus://repo/my-app/process/CheckoutFlow → Step 3: validatePayment → calls fetchRates (external) 4. Root cause: fetchRates calls external API without proper timeout + Repository: my-app Index: current ``` + +With a single indexed repository, step 0 returns `total: 1` and the `repo` +argument drops out of every call above. diff --git a/gitnexus/skills/gitnexus-exploring.md b/gitnexus/skills/gitnexus-exploring.md index f483c2fd6..46fc187ce 100644 --- a/gitnexus/skills/gitnexus-exploring.md +++ b/gitnexus/skills/gitnexus-exploring.md @@ -13,10 +13,22 @@ description: "Use when the user asks how code works, wants to understand archite - "Where is the database logic?" - Understanding code you haven't seen before +## Bind the repository first + +Step 1 discovers what is indexed; every call after it must say which of those +it means. With one indexed repository, use the examples below as written. With +more than one, pass `repo` on every call: an omitted `repo` normally errors, +but under an MCP policy with a configured default it resolves to that default +silently. If you cannot tell which repository is meant, stop and ask. Report +the bound repository and index freshness alongside your explanation. + +`list_repos` is paginated, so page with `offset: pagination.nextOffset` until +`hasMore` is false before concluding a repository is absent. + ## Workflow ``` -1. READ gitnexus://repos → Discover indexed repos +1. list_repos {} or READ gitnexus://repos → Discover indexed repos 2. READ gitnexus://repo/{name}/context → Codebase overview, check staleness 3. query({search_query: ""}) → Find related execution flows 4. context({name: ""}) → Deep dive on specific symbol @@ -28,12 +40,14 @@ description: "Use when the user asks how code works, wants to understand archite ## Checklist ``` +- [ ] list_repos {} — bind repo; explicit repo when >1 indexed, ask if ambiguous - [ ] READ gitnexus://repo/{name}/context - [ ] query for the concept you want to understand - [ ] Review returned processes (execution flows) - [ ] context on key symbols for callers/callees - [ ] READ process resource for full execution traces - [ ] Read source files for implementation details +- [ ] State the repository and index freshness with the explanation ``` ## Resources @@ -50,7 +64,7 @@ description: "Use when the user asks how code works, wants to understand archite **query** — find execution flows related to a concept: ``` -query({search_query: "payment processing"}) +query({search_query: "payment processing", repo: "my-app"}) → Processes: CheckoutFlow, RefundFlow, WebhookHandler → Symbols grouped by flow with file locations ``` @@ -58,16 +72,20 @@ query({search_query: "payment processing"}) **context** — 360-degree view of a symbol: ``` -context({name: "validateUser"}) +context({name: "validateUser", repo: "my-app"}) → Incoming calls: loginHandler, apiMiddleware → Outgoing calls: checkToken, getUserById → Processes: LoginFlow (step 2/5), TokenRefresh (step 1/3) ``` +`repo` is required once more than one repository is indexed, and may be omitted +with a single one. + ## Example: "How does payment processing work?" ``` -1. READ gitnexus://repo/my-app/context → 918 symbols, 45 processes +1. list_repos {} → total: 1 (my-app) — bind it + READ gitnexus://repo/my-app/context → 918 symbols, 45 processes 2. query({search_query: "payment processing"}) → CheckoutFlow: processPayment → validateCard → chargeStripe → RefundFlow: initiateRefund → calculateRefund → processRefund @@ -75,4 +93,8 @@ context({name: "validateUser"}) → Incoming: checkoutHandler, webhookHandler → Outgoing: validateCard, chargeStripe, saveTransaction 4. Read src/payments/processor.ts for implementation details +5. Answer, noting: Repository my-app, index current ``` + +Had step 1 returned two repositories, every call above would carry +`repo: "my-app"`. diff --git a/gitnexus/skills/gitnexus-impact-analysis.md b/gitnexus/skills/gitnexus-impact-analysis.md index 2e34f86f6..85d90c90d 100644 --- a/gitnexus/skills/gitnexus-impact-analysis.md +++ b/gitnexus/skills/gitnexus-impact-analysis.md @@ -14,13 +14,42 @@ description: "Use when the user wants to know what will break if they change som - Before making non-trivial code changes - Before committing — to understand what your changes affect +## Bind the repository first + +Impact analysis is the gate that authorizes an edit, so it must answer for the +repository you are about to edit. + +Call `list_repos {}` before the first tool call. With one indexed repository, +use the examples below as written. With more than one, pass `repo` on every +call: an omitted `repo` normally errors, but under an MCP policy with a +configured default it resolves to that default silently. If you cannot tell +which repository is meant, stop and ask — every result below an ambiguous +identity inherits the ambiguity. `list_repos` is paginated, so page with +`offset: pagination.nextOffset` until `hasMore` is false before concluding a +repository is absent. + +`detect_changes` takes `worktree` when your changes are in a linked worktree +the MCP server was not launched from. The server auto-detects a worktree only +when it was launched from inside one; otherwise `git diff` runs in the wrong +checkout and reports zero changed symbols — a false clean check that carries +none of the degradation flags described below. In the CLI fallbacks, `--repo .` +means the current checkout; pass the intended repository path instead when you +are not standing in it. + +State the bound identity with your risk report: + +``` +Repository: () Worktree: Index: , behind HEAD +``` + ## Workflow ``` +0. list_repos {} → Bind repo (and worktree) 1. impact({target: "X", direction: "upstream"}) or `node .gitnexus/run.cjs impact "X" --direction upstream --repo .` 2. READ gitnexus://repo/{name}/processes → Check affected execution flows 3. detect_changes({scope: "all"}) or `node .gitnexus/run.cjs detect-changes --scope all --repo .` -4. Assess risk and report to user +4. Assess risk and report to user, echoing repo/worktree/index identity ``` > If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal. @@ -29,12 +58,14 @@ description: "Use when the user wants to know what will break if they change som ## Checklist ``` +- [ ] list_repos {} — bind repo; explicit repo when >1 indexed, ask if ambiguous - [ ] impact({target, direction: "upstream"}) or CLI fallback to find dependents - [ ] Review d=1 items first (these WILL BREAK) - [ ] Check high-confidence (>0.8) dependencies - [ ] READ processes to check affected execution flows - [ ] detect_changes({scope: "all"}) or CLI fallback for pre-commit check -- [ ] Assess risk level and report to user +- [ ] Confirm the checkout you edited is the checkout that was diffed +- [ ] Assess risk level and report, stating repo/worktree/index identity ``` ## Understanding Output @@ -62,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 --direction upstream --repo .` instead: @@ -69,6 +109,7 @@ treating the symbol as safe to change or delete. ``` impact({ target: "validateUser", + repo: "my-app", // required once >1 repository is indexed direction: "upstream", minConfidence: 0.8, maxDepth: 3 @@ -92,10 +133,26 @@ detect_changes({scope: "all"}) → Risk: MEDIUM ``` +Add `repo` once more than one repository is indexed, and `worktree: ""` when your changes are in a linked worktree the server was not launched +from. + +`partial: true` (a graph query failed) or `truncated: true` (the changed-symbol +listing was capped) means the result is short of the truth, and reads like +`UNKNOWN` above: a zero there means unseen, not unaffected. Re-run it rather +than tick the pre-commit check. + +A wrong-worktree zero carries neither flag and is shape-identical to a genuine +clean result, so confirm the checkout you edited is the one that was diffed +before treating an empty change set as a passed check. + ## Example: "What breaks if I change validateUser?" ``` -1. impact({target: "validateUser", direction: "upstream"}) or `node .gitnexus/run.cjs impact "validateUser" --direction upstream --repo .` +0. list_repos {} + → total: 2 (my-app, billing-api) — both define validateUser, so bind explicitly + +1. impact({target: "validateUser", repo: "my-app", direction: "upstream"}) or `node .gitnexus/run.cjs impact "validateUser" --direction upstream --repo .` → d=1: loginHandler, apiMiddleware (WILL BREAK) → d=2: authRouter, sessionManager (LIKELY AFFECTED) @@ -103,4 +160,8 @@ detect_changes({scope: "all"}) → LoginFlow and TokenRefresh touch validateUser 3. Risk: 2 direct callers, 2 processes = MEDIUM + Repository: my-app (/abs/path/my-app) Worktree: same Index: current ``` + +With a single indexed repository, step 0 returns `total: 1` and the `repo` +argument drops out of every call above. diff --git a/gitnexus/skills/gitnexus-plan/README.md b/gitnexus/skills/gitnexus-plan/README.md index f7fe58ab9..153374bb7 100644 --- a/gitnexus/skills/gitnexus-plan/README.md +++ b/gitnexus/skills/gitnexus-plan/README.md @@ -124,12 +124,17 @@ phase that needs them. statement-level claims (never reconstructs fake edges). - No GitNexus at all → fallback mode: targeted grep/read exploration, findings labelled **source-derived**, with a recommendation to index. -- Reading or publishing a plan requires Linux `/proc/self/fd`, `O_DIRECTORY`, - and `O_NOFOLLOW`; publication also requires a validated absolute Python 3 - PATH candidate with libc `renameat2(RENAME_NOREPLACE)` support, a - writable target repository, and a shared filesystem for the plan and - Git-admin vault. The writer fails closed when those guarantees are - unavailable; it never redirects the plan elsewhere. +- Reading or publishing a plan requires `O_DIRECTORY` and `O_NOFOLLOW`, plus + `/proc/self/fd` on Linux; every other platform is refused. No interpreter is + spawned and no native code is loaded. Publication is `link(2)`, which fails + rather than replaces when the destination name is taken. Linux resolves every + name against a held descriptor, so a parent swapped mid-write cannot redirect + the operation; macOS has no equivalent path and instead pins each directory + with an open descriptor and re-proves the chain either side of every step, + which detects such a swap and aborts. Publishing also needs a writable target + repository and a shared filesystem for the plan and Git-admin vault. The + writer fails closed when those guarantees are unavailable; it never redirects + the plan elsewhere. ## Limitations diff --git a/gitnexus/skills/gitnexus-plan/references/evidence-provenance.md b/gitnexus/skills/gitnexus-plan/references/evidence-provenance.md index c686599da..3df5a046d 100644 --- a/gitnexus/skills/gitnexus-plan/references/evidence-provenance.md +++ b/gitnexus/skills/gitnexus-plan/references/evidence-provenance.md @@ -98,8 +98,11 @@ excluded. ## Safe existing-plan read contract -`read-plan` fails closed unless Linux `/proc/self/fd`, `O_DIRECTORY`, and -`O_NOFOLLOW` are available. It resolves the exact Git top-level, opens the +`read-plan` fails closed unless the host platform can resolve names against a +held directory descriptor: Linux `/proc/self/fd` with `O_DIRECTORY` and +`O_NOFOLLOW`, or macOS `O_DIRECTORY`/`O_NOFOLLOW`. Every other platform is +refused outright — an unverified read is not a degraded read, it is a different, +racy operation. It resolves the exact Git top-level, opens the repository root and every plan parent as held no-follow directory descriptors, rejects missing, symlink, non-directory, and escaping parents, and opens the leaf with `O_NOFOLLOW`. It reads at most 16 MiB from that held file descriptor, @@ -109,13 +112,17 @@ Neither Deepen nor work may parse bytes obtained before or outside this receipt. ## Safe generated-plan write contract -The writer fails closed unless Linux `/proc/self/fd`, `O_DIRECTORY`, -`O_NOFOLLOW`, and Python 3 with libc `renameat2(RENAME_NOREPLACE)` support are -available. Python may live in `/usr/local`, a Nix profile, or another absolute -PATH directory, but the helper accepts only a resolved executable and -containing directory owned by root or the current user and not writable by -group/other. The resolved executable is opened without following links and -invoked through that held descriptor. Relative PATH entries are ignored. The plan parent and the +The writer fails closed unless the host platform offers `O_DIRECTORY` and +`O_NOFOLLOW`, plus `/proc/self/fd` on Linux. It spawns no interpreter and loads +no native code: publication is `link(2)`, which is atomic, fails `EEXIST` when +the destination name is taken, and refuses a symlinked destination without +following it — the same no-replace guarantee `renameat2(RENAME_NOREPLACE)` and +`renameatx_np(RENAME_EXCL)` provide, available through `fs.linkSync` on every +supported platform. The temporary name is unlinked once the link succeeds; the +published file is the same inode the writer created and verified, so every +identity check downstream holds by construction. A link that succeeds followed +by an unlink that fails leaves the plan published and is reported as success, +because it is one. The plan parent and the repository's Git-admin directory must also share a filesystem. It resolves the target repository's exact Git top-level, opens that root and every destination parent as held no-follow directory descriptors, creates missing @@ -128,15 +135,45 @@ The writer creates a random exclusive temporary file relative to the held final parent descriptor and keeps its no-follow descriptor open. It writes and flushes the bytes, binds the temporary name to the opened inode, and hashes the open file before publication. Immediately before publication it revalidates -the parent and the temporary path, inode, size, and digest. Publication uses an -atomic no-replace move relative to the held directory descriptor. Initial mode -therefore cannot overwrite a destination that appears after the absent check. +the parent and the temporary path, inode, size, and digest. Publication links +the temporary name to the destination relative to the held directory +descriptor, which fails rather than replaces if the destination is taken. +Initial mode therefore cannot overwrite a destination that appears after the +absent check. The writer then flushes the directory and revalidates the committed path by opening it with `O_NOFOLLOW`, hashing both the original temporary fd and the path-bound fd, and performing a second descriptor-anchored path identity check after hashing. A detected mutation or replacement aborts instead of accepting mixed-era output. +### Linux anchors, macOS verifies + +The two platforms reach the same destination by different proofs, and the +difference is real enough to state rather than smooth over. + +On Linux every name resolves through `/proc/self/fd//`, a magic link +the kernel resolves against the inode the descriptor already holds. The names +above it are never re-walked, so an attacker who renames a parent between the +check and the use cannot redirect the operation. The race is impossible, not +merely detected. + +macOS has no such path. `/dev/fd/` is a devfs node, not a magic link: it can +be opened, but nothing can be resolved through it. `open("/dev/fd//child")` +returns `ENOENT`, and `realpath` of it returns `/dev/fd/` rather than the +directory's path — measured on macOS 26, not inferred. Node exposes no `openat`, +no `dir_fd` parameter, and no FFI, so on macOS the writer resolves names +lexically with `O_NOFOLLOW` at every component, holds an open descriptor on +every directory in the chain for the whole operation, and proves before *and* +after each step that the chain still names exactly the inodes it is holding. +Holding the descriptors is what makes the recorded inode numbers trustworthy: +an open descriptor pins its inode, so a freed number cannot be recycled beneath +the walk. + +What that buys is detection rather than prevention. A parent swapped inside the +window between a check and its use is caught by the check that follows, and the +operation aborts having written nothing — but on Linux it could not have +happened at all. No published byte escapes verification on either platform. + `--replace` accepts only a pre-existing regular file and is reserved for Deepen; without it, accidental overwrite is rejected. It also requires the exact canonical `generated_plan_path` and `plan_digest` from the same session's diff --git a/gitnexus/skills/gitnexus-plan/scripts/evidence-provenance.mjs b/gitnexus/skills/gitnexus-plan/scripts/evidence-provenance.mjs index 181d2120b..793fe4cd8 100644 --- a/gitnexus/skills/gitnexus-plan/scripts/evidence-provenance.mjs +++ b/gitnexus/skills/gitnexus-plan/scripts/evidence-provenance.mjs @@ -479,11 +479,11 @@ function resolveOwnGitTopLevel(absolute) { if (result.status !== 0) return null; let topLevel; try { - topLevel = fs.realpathSync(decodeUtf8(result.stdout, 'nested repository root').trim()); + topLevel = fs.realpathSync.native(decodeUtf8(result.stdout, 'nested repository root').trim()); } catch { return null; } - return topLevel === fs.realpathSync(absolute) ? topLevel : null; + return topLevel === fs.realpathSync.native(absolute) ? topLevel : null; } function readOwnGitlinkHead(absolute) { @@ -616,17 +616,30 @@ function filesystemObject(absolute, expectedKind, mutationGuards, testHooks) { throw new Error(`Unsupported filesystem object at ${absolute}`); } -function guardPathParents(repo, repoPath, mutationGuards) { +// Every dirty path re-walks its own parents, and dirty paths overwhelmingly +// share them — the repository root is re-stat'ed once per path. `guarded` is +// per-snapshot and remembers which absolute directories already carry a guard, +// so each distinct directory is stat'ed and guarded exactly once. +// +// Keeping the first-seen identity is the conservative choice: verifyGuards +// re-checks every guard against the filesystem at the end, so a directory that +// changes after it was guarded still fails there. Skipping a re-stat cannot hide +// a change; it only avoids recording the same directory twice. +function guardPathParents(repo, repoPath, mutationGuards, guarded) { const components = repoPath.split('/'); let current = repo; - const rootStat = fs.lstatSync(repo, { bigint: true }); - mutationGuards.push({ - type: 'directory', - absolute: repo, - identity: stableDirectoryIdentity(rootStat), - }); + if (!guarded.has(repo)) { + guarded.add(repo); + mutationGuards.push({ + type: 'directory', + absolute: repo, + identity: stableDirectoryIdentity(fs.lstatSync(repo, { bigint: true })), + }); + } for (const component of components.slice(0, -1)) { current = path.join(current, component); + // Already proved a real directory and already guarded on an earlier path. + if (guarded.has(current)) continue; let stat; try { stat = fs.lstatSync(current, { bigint: true }); @@ -638,6 +651,7 @@ function guardPathParents(repo, repoPath, mutationGuards) { throw new Error(`Refusing to traverse symlink parent for ${repoPath}`); } if (!stat.isDirectory()) return; + guarded.add(current); mutationGuards.push({ type: 'directory', absolute: current, @@ -646,81 +660,153 @@ function guardPathParents(repo, repoPath, mutationGuards) { } } -function recordAnchoredAbsence(repo, repoPath, mutationGuards) { - requireDescriptorAnchoring(); - const flags = - fs.constants.O_RDONLY | - fs.constants.O_DIRECTORY | - fs.constants.O_NOFOLLOW | - (fs.constants.O_CLOEXEC ?? 0); - const descriptors = []; - let retainedFd; - try { - let currentFd = fs.openSync(repo, flags); - descriptors.push(currentFd); - const components = repoPath.split('/'); - for (let index = 0; index < components.length; index += 1) { - const component = components[index]; - const child = descriptorPath(currentFd, component); - let childStat; - try { - childStat = fs.lstatSync(child, { bigint: true }); - } catch (error) { - if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') throw error; - const parentStat = fs.fstatSync(currentFd, { bigint: true }); - if (!parentStat.isDirectory()) { - throw new Error(`Absence parent is no longer a directory for ${repoPath}`); - } - retainedFd = currentFd; - mutationGuards.push({ - type: 'absence', - fd: retainedFd, - childName: component, - repoPath, - parentIdentity: stableDirectoryIdentity(parentStat), - parentMutationIdentity: statIdentity(parentStat), - }); - for (const fd of descriptors) { - if (fd !== retainedFd) fs.closeSync(fd); - } - return; - } - if (index === components.length - 1) { - throw new Error(`${repoPath} appeared while its absence was being anchored`); - } - if (childStat.isSymbolicLink() || !childStat.isDirectory()) { - throw new Error(`Refusing a non-directory parent while anchoring absence for ${repoPath}`); - } - const nextFd = fs.openSync(child, flags); - descriptors.push(nextFd); - currentFd = nextFd; - } - throw new Error(`Could not anchor absence for ${repoPath}`); - } catch (error) { - for (const fd of descriptors) { - if (fd === retainedFd) continue; - try { - fs.closeSync(fd); - } catch { - // Preserve the primary absence-anchoring error. - } - } - throw error; +// A bound, not a bug: the absence cache deduplicates correctly and leaks nothing, +// but citedPaths is caller-supplied and unbounded, so a pathological snapshot +// could hold more descriptors than the process is allowed (macOS +// kern.maxfilesperproc is 24576). The peak precedes a `git` spawn, so exhaustion +// would surface as a git failure misreported as evidence instability. +// +// Refuse rather than evict: closing a cached descriptor would silently break the +// pinned chain of an absence guard that was already recorded against it, which is +// exactly the inode-recycling hole the pins exist to close. +const ABSENCE_ANCHOR_LIMITS = Object.freeze({ maxPinnedDirectories: 4096 }); + +// Every no-follow read and every exclusive create in this file uses one of these +// two, so a change lands in one place rather than in seven. +const VERIFIED_READ_FLAGS = + fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_CLOEXEC ?? 0); +const VERIFIED_CREATE_FLAGS = + fs.constants.O_RDWR | + fs.constants.O_CREAT | + fs.constants.O_EXCL | + fs.constants.O_NOFOLLOW | + (fs.constants.O_CLOEXEC ?? 0); + +function requireAbsenceAnchorCapacity(cache) { + if (cache.size >= ABSENCE_ANCHOR_LIMITS.maxPinnedDirectories) { + throw new Error( + `Absence anchoring exceeds ${ABSENCE_ANCHOR_LIMITS.maxPinnedDirectories} pinned directories`, + ); } } -function materializeRecord(repo, statusRecord, layers, mutationGuards, testHooks) { +const ANCHORED_DIRECTORY_FLAGS = + fs.constants.O_RDONLY | + fs.constants.O_DIRECTORY | + fs.constants.O_NOFOLLOW | + (fs.constants.O_CLOEXEC ?? 0); + +// Every absence receipt is verified long after its walk returns, so the chain +// that produced it has to stay pinned until the snapshot ends — an unpinned inode +// number can be recycled by a replacement directory that then reproduces the +// recorded identity exactly. Absent cited paths overwhelmingly share prefixes, so +// the walked directories are cached per snapshot and keyed by repo-relative +// prefix: one open descriptor and one anchored walk per distinct directory rather +// than per path. snapshotEvidence owns every descriptor in this cache and closes +// each exactly once; guards only borrow them for verification. +function anchoredAbsenceRoot(repo, cache) { + const cached = cache.get(''); + if (cached) return cached; + requireAbsenceAnchorCapacity(cache); + const fd = openVerifiedDirectory(repo, ANCHORED_DIRECTORY_FLAGS); + const handle = { + fd, + expectedPath: repo, + chain: [ + { expectedPath: repo, identity: stableDirectoryIdentity(fs.fstatSync(fd, { bigint: true })) }, + ], + descriptors: [fd], + }; + cache.set('', handle); + return handle; +} + +function recordAnchoredAbsence(repo, repoPath, mutationGuards, cache) { + requireDescriptorAnchoring(); + const components = repoPath.split('/'); + let handle = anchoredAbsenceRoot(repo, cache); + let prefix = ''; + for (let index = 0; index < components.length; index += 1) { + const component = components[index]; + const isFinal = index === components.length - 1; + prefix = prefix === '' ? component : `${prefix}/${component}`; + // The final component is always re-checked against the filesystem: it is the + // one whose absence is being recorded, and a cached answer would be a stale + // one. Only the prefix directories are reused. + const cached = isFinal ? undefined : cache.get(prefix); + if (cached) { + handle = cached; + continue; + } + const child = anchoredChild(handle, component); + let childStat; + try { + childStat = lstatChild(child); + } catch (error) { + if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') throw error; + const parentStat = fs.fstatSync(handle.fd, { bigint: true }); + if (!parentStat.isDirectory()) { + throw new Error(`Absence parent is no longer a directory for ${repoPath}`); + } + mutationGuards.push({ + type: 'absence', + // The handle is the holder the guard verifies against, and `ref` is the + // child path already built through the anchoredChild chokepoint — the + // guard must never re-derive that name itself. + handle, + ref: child, + fd: handle.fd, + repoPath, + parentMutationIdentity: statIdentity(parentStat), + }); + return; + } + if (isFinal) { + throw new Error(`${repoPath} appeared while its absence was being anchored`); + } + if (childStat.isSymbolicLink() || !childStat.isDirectory()) { + throw new Error(`Refusing a non-directory parent while anchoring absence for ${repoPath}`); + } + requireAbsenceAnchorCapacity(cache); + const childFd = openVerifiedDirectory(child.path, ANCHORED_DIRECTORY_FLAGS); + const expectedPath = path.join(handle.expectedPath, component); + let next; + try { + if (!anchoringBackend().descriptorMatchesChild(childFd, expectedPath, childStat)) { + throw new Error( + `Absence parent descriptor does not match its verified inode for ${repoPath}`, + ); + } + next = { + fd: childFd, + expectedPath, + chain: [...handle.chain, { expectedPath, identity: stableDirectoryIdentity(childStat) }], + descriptors: [...handle.descriptors, childFd], + }; + } catch (error) { + fs.closeSync(childFd); + throw error; + } + cache.set(prefix, next); + handle = next; + } + throw new Error(`Could not anchor absence for ${repoPath}`); +} + +function materializeRecord(repo, statusRecord, layers, mutationGuards, testHooks, walkState) { const head = layers.head(statusRecord.path); const index = layers.index(statusRecord.path); const expectedKind = index.kind === 'gitlink' || head.kind === 'gitlink' ? 'gitlink' : null; - guardPathParents(repo, statusRecord.path, mutationGuards); + guardPathParents(repo, statusRecord.path, mutationGuards, walkState.guardedDirectories); const filesystem = filesystemObject( path.join(repo, ...statusRecord.path.split('/')), expectedKind, mutationGuards, testHooks, ); - if (filesystem.kind === ABSENT) recordAnchoredAbsence(repo, statusRecord.path, mutationGuards); + if (filesystem.kind === ABSENT) { + recordAnchoredAbsence(repo, statusRecord.path, mutationGuards, walkState.absenceCache); + } if (statusRecord.directory_hint && filesystem.kind !== 'directory') { throw new Error( `Git reported an embedded directory but found ${filesystem.kind}: ${statusRecord.path}`, @@ -789,9 +875,15 @@ export function serializeDirtyRecords(entries) { } function assertRepository(repoInput) { - const repo = fs.realpathSync(requireString(repoInput, 'repo')); + // realpathSync.native, not realpathSync: the JS resolver preserves a Windows + // 8.3 short component (C:\Users\RUNNER~1\...) while git always reports the long + // form, so the two would never compare equal and every caller would be told the + // worktree root is not the worktree root it just named. + const repo = fs.realpathSync.native(requireString(repoInput, 'repo')); const topLevelResult = git(repo, ['rev-parse', '--show-toplevel']); - const topLevel = fs.realpathSync(decodeUtf8(topLevelResult.stdout, 'repository root').trim()); + const topLevel = fs.realpathSync.native( + decodeUtf8(topLevelResult.stdout, 'repository root').trim(), + ); if (topLevel !== repo) throw new Error(`--repo must be the Git worktree root (${topLevel})`); return repo; } @@ -882,17 +974,48 @@ function stableFileIdentity(stat) { return [stat.dev, stat.ino, stat.mode, stat.size].map(String).join(':'); } +// The two backends below differ in one decisive way, and it is worth stating +// plainly because the security properties are not the same. +// +// Linux ANCHORS. A name is resolved through /proc/self/fd//, which +// starts the walk at the inode the descriptor holds, so a parent that is renamed +// away cannot be traversed at all: the descriptor keeps pointing at the original +// directory and the impostor planted at the same name is simply never reached. +// +// macOS VERIFIES. Node cannot resolve a name relative to a descriptor there — +// /dev/fd/ is not a magic link (it stats as the directory but every attempt +// to traverse a child through it returns ENOENT), and fcntl F_GETPATH is a +// name-cache snapshot rather than a live anchor. So the Darwin backend resolves +// lexically, holds an open descriptor on every element of the chain, and proves +// before and after each operation that the path chain still names exactly the +// inodes it is holding. That DETECTS a swapped parent and aborts the write; it +// does not make the swap impossible the way the Linux path does. A swap landing +// inside the window between a check and the call it guards is caught by the +// following check, after the fact, rather than being unreachable. +// +// Every other platform gets neither and is refused outright. function requireDescriptorAnchoring() { - if ( - process.platform !== 'linux' || - fs.constants.O_DIRECTORY === undefined || - fs.constants.O_NOFOLLOW === undefined || - !fs.existsSync('/proc/self/fd') - ) { - throw new Error( - 'Safe generated-plan writes require Linux /proc/self/fd and O_DIRECTORY/O_NOFOLLOW; refusing an unanchored write', - ); + const directoryFlagsAvailable = + fs.constants.O_DIRECTORY !== undefined && fs.constants.O_NOFOLLOW !== undefined; + if (process.platform === 'linux') { + if (!directoryFlagsAvailable || !fs.existsSync('/proc/self/fd')) { + throw new Error( + 'Safe generated-plan writes require Linux /proc/self/fd and O_DIRECTORY/O_NOFOLLOW; refusing an unanchored write', + ); + } + return; } + if (process.platform === 'darwin') { + if (!directoryFlagsAvailable) { + throw new Error( + 'Safe generated-plan writes require macOS O_DIRECTORY/O_NOFOLLOW; refusing an unverified write', + ); + } + return; + } + throw new Error( + `Safe generated-plan writes require Linux /proc/self/fd or macOS O_DIRECTORY/O_NOFOLLOW; ${process.platform} offers neither, so refusing an unanchored write`, + ); } function descriptorPath(fd, childName) { @@ -900,157 +1023,352 @@ function descriptorPath(fd, childName) { return childName === undefined ? base : path.join(base, childName); } -function externalDescriptorPath(fd, childName) { - const base = `/proc/${process.pid}/fd/${fd}`; - return childName === undefined ? base : path.join(base, childName); +// Directory opens are plain O_RDONLY|O_DIRECTORY|O_NOFOLLOW|O_CLOEXEC on both +// platforms, and deliberately nothing else. +// +// O_NOFOLLOW_ANY (macOS 11+) used to be ORed in here on the theory that XNU +// ignores unrecognized open flag bits, so it would be inert where unsupported. +// That was wrong: combined with O_DIRECTORY macOS rejects it outright with +// EINVAL, and every directory open on Darwin failed. It is gone and is not +// coming back behind a probe or a degrade-on-EINVAL path — the per-component +// O_NOFOLLOW walk is what delivers the guarantee. Rust's cap-std, the closest +// reference implementation of this problem, has not adopted O_NOFOLLOW_ANY +// either (their issue #179 is still open). +function openVerifiedDirectory(absolute, flags) { + return fs.openSync(absolute, flags); } -const RENAME_NOREPLACE_SCRIPT = String.raw` -import ctypes -import errno -import os -import sys - -libc = ctypes.CDLL(None, use_errno=True) -try: - renameat2 = libc.renameat2 -except AttributeError: - print("libc does not expose renameat2", file=sys.stderr) - raise SystemExit(125) - -renameat2.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint] -renameat2.restype = ctypes.c_int -result = renameat2(-100, os.fsencode(sys.argv[1]), -100, os.fsencode(sys.argv[2]), 1) -if result != 0: - error_number = ctypes.get_errno() - error_name = errno.errorcode.get(error_number, "UNKNOWN") - print(f"renameat2 RENAME_NOREPLACE failed: {error_name}: {os.strerror(error_number)}", file=sys.stderr) - raise SystemExit(17 if error_number == errno.EEXIST else 126) -`; - -let atomicMoverPath; - -function spawnHeldExecutable(executable, args, options) { - const before = fs.fstatSync(executable.fd, { bigint: true }); - if (!before.isFile() || statIdentity(before) !== executable.identity) { - throw new Error('Validated Python executable changed before invocation'); - } - const result = spawnSync('/proc/self/fd/3', args, { - ...options, - stdio: ['ignore', 'pipe', 'pipe', executable.fd], - }); - const after = fs.fstatSync(executable.fd, { bigint: true }); - assertStableIdentity(before, after, 'validated Python executable'); - return result; +// File opens additionally get O_NONBLOCK, which directory opens do not need: +// it stops a FIFO swapped in at the target name from wedging the process on +// open. The identity comparison that follows rejects the FIFO anyway, but only +// if we ever get as far as running it. +function openVerifiedFile(absolute, flags, mode) { + const nonBlocking = flags | (fs.constants.O_NONBLOCK ?? 0); + return mode === undefined + ? fs.openSync(absolute, nonBlocking) + : fs.openSync(absolute, nonBlocking, mode); } -function validatedPathExecutable(candidate) { - if (!path.isAbsolute(candidate)) return null; - const candidateDirectory = path.dirname(candidate); - let resolvedDirectory; - let resolved; - let directoryStats; - let executableStat; +// The publish primitive, identical on both platforms. +// +// link() is the portable no-replace publish: it fails with EEXIST if the +// destination name is taken — by a regular file, by a directory, or by a symlink, +// live or dangling — and it never follows that symlink to clobber its target. +// It also works where renameat2(RENAME_NOREPLACE) does not, notably v9fs, which +// is why the WSL2 9p case that used to fail every time now works. +// +// The published file is the same inode as the temporary, so every identity +// comparison the callers already make still holds, and validateCommittedPlan +// becomes strictly stronger: it compares the destination against the exact inode +// whose bytes were fsynced. +// +// On Linux both paths are /proc/self/fd//, so the publish is anchored +// to the held parent descriptors exactly like every other operation. +// link(2) BUGS: "On NFS filesystems, the return code may be wrong in case the NFS +// server performs the link creation and dies before it can say so. Use stat(2) to +// find out if the link got created." open(2) NOTES gives the remedy this +// implements: on a reported failure, stat the source and see whether its link +// count reached 2. A false positive would need someone to have hardlinked a +// 16-random-byte name inside a directory we hold open — and validateCommittedPlan +// still proves the destination is the exact temporary inode afterwards. +function linkCreatedDespiteError(sourcePath) { try { - resolvedDirectory = fs.realpathSync(candidateDirectory); - resolved = fs.realpathSync(candidate); - const resolvedExecutableDirectory = fs.realpathSync(path.dirname(resolved)); - directoryStats = [...new Set([resolvedDirectory, resolvedExecutableDirectory])].map( - (directory) => fs.statSync(directory), - ); - executableStat = fs.lstatSync(resolved); - fs.accessSync(resolved, fs.constants.X_OK); + return fs.statSync(sourcePath, { bigint: true }).nlink === 2n; } catch { - return null; + return false; } - if ( - directoryStats.some((stat) => !stat.isDirectory()) || - !executableStat.isFile() || - executableStat.isSymbolicLink() - ) { - return null; - } - const uid = typeof process.getuid === 'function' ? process.getuid() : null; - const trustedOwner = (stat) => uid === null || stat.uid === 0 || stat.uid === uid; - if ( - directoryStats.some((stat) => !trustedOwner(stat) || (stat.mode & 0o022) !== 0) || - !trustedOwner(executableStat) || - (executableStat.mode & 0o022) !== 0 - ) { - return null; - } - return resolved; } -function resolveAtomicMover() { - if (atomicMoverPath) return atomicMoverPath; - const candidates = new Set(); - for (const entry of (process.env.PATH ?? '').split(path.delimiter)) { - if (entry && path.isAbsolute(entry)) candidates.add(path.join(entry, 'python3')); - } - for (const entry of ['/usr/local/bin/python3', '/usr/bin/python3', '/bin/python3']) { - candidates.add(entry); - } - for (const candidate of candidates) { - const resolved = validatedPathExecutable(candidate); - if (!resolved) continue; - let fd; - try { - fd = fs.openSync( - resolved, - fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_CLOEXEC ?? 0), - ); - } catch { - continue; +function linkNoReplace(sourcePath, destinationPath) { + try { + fs.linkSync(sourcePath, destinationPath); + } catch (error) { + // Callers treat "destination taken" as a distinct outcome, not a failure. + if (error?.code === 'EEXIST') return false; + if (!linkCreatedDespiteError(sourcePath)) { + // FAT, Coda, and some SMB/FUSE/virtiofs mounts have no hardlinks at all. + // Git falls back to rename here, but git can afford to lose collision + // detection because its objects are content-addressed; a plan destination + // is a plain name, so a replacing rename would silently clobber whatever + // is already there. Refuse loudly instead. + if (error?.code === 'EPERM' || error?.code === 'ENOTSUP' || error?.code === 'EMLINK') { + throw new Error( + `Generated-plan publication requires hard links, which this filesystem refused (${error.code}); refusing to fall back to a replacing rename`, + ); + } + throw error; } - const opened = fs.fstatSync(fd, { bigint: true }); - const executable = { fd, identity: statIdentity(opened), resolved }; - const version = spawnHeldExecutable( - executable, - ['-I', '-S', '-c', 'import sys; print(sys.version_info[0])'], - { - encoding: 'utf8', - env: { ...process.env, LANG: 'C', LC_ALL: 'C' }, - timeout: 10_000, - windowsHide: true, - }, - ); - if (version.status === 0 && version.stdout.trim() === '3') { - atomicMoverPath = executable; - return executable; - } - fs.closeSync(fd); } - throw new Error( - 'Safe generated-plan publication requires a trusted absolute Python 3 PATH candidate with libc renameat2 support', - ); -} - -function atomicMoveNoReplace(source, destination) { - const mover = resolveAtomicMover(); - const result = spawnHeldExecutable( - mover, - ['-I', '-S', '-c', RENAME_NOREPLACE_SCRIPT, source, destination], - { - encoding: 'utf8', - env: { ...process.env, LANG: 'C', LC_ALL: 'C' }, - timeout: 10_000, - windowsHide: true, - }, - ); - if (result.error) throw result.error; - if (result.status === 17) return false; - if (result.status !== 0) { - throw new Error( - `Atomic no-replace move failed (${result.status}): ${(result.stderr ?? '').trim()}`, - ); + try { + fs.unlinkSync(sourcePath); + } catch { + // The link succeeded, so the plan IS published. A temporary name left behind + // is a stray file, not an unpublished plan: reporting it as a failure would + // be a lie, and rolling back would unpublish a plan that is already live. } return true; } -function lstatOptional(absolute) { +// A directory holder is anything that owns a verified chain: a plan-parent +// handle, a ref's parent directory, or an absence guard. Two arrays describe it, +// both root-first and the same length — `chain` records each element's expected +// path and dev/ino/mode, and `descriptors` holds an open descriptor on each. +// +// Holding those descriptors is load-bearing rather than decorative. dev/ino/mode +// is unique only among *live* inodes: an inode number freed by an rmdir is handed +// straight back to the next mkdir, so a replacement directory can reproduce a +// recorded identity exactly. An open descriptor pins the inode, so the number +// cannot be recycled for as long as the holder exists. +function verifyPinnedDescriptors(holder) { + const { chain, descriptors } = holder; + if (!Array.isArray(descriptors) || descriptors.length !== chain.length) { + throw new Error('Generated-plan parent chain is missing the descriptors that pin it'); + } + chain.forEach((item, index) => { + const pinned = fs.fstatSync(descriptors[index], { bigint: true }); + if (!pinned.isDirectory() || stableDirectoryIdentity(pinned) !== item.identity) { + throw new Error('Generated-plan parent descriptor changed during the write'); + } + }); +} + +function verifyLexicalChain(holder) { + for (const item of holder.chain) { + let lexical; + try { + lexical = fs.lstatSync(item.expectedPath, { bigint: true }); + } catch (error) { + if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') throw error; + // A parent renamed out from under us is a mismatch, not a missing file: + // reporting the raw ENOENT would leak an unrelated-looking error out of a + // check whose whole job is to say the chain no longer holds. + throw new Error('Generated-plan lexical parent no longer matches its directory descriptor'); + } + if ( + lexical.isSymbolicLink() || + !lexical.isDirectory() || + stableDirectoryIdentity(lexical) !== item.identity + ) { + throw new Error('Generated-plan lexical parent no longer matches its directory descriptor'); + } + } +} + +// The whole platform seam, in five methods. Everything else an operation does is +// identical on both platforms and lives in the shared functions below. +// +// Only two things actually differ: how a name becomes a path, and what guard +// wraps the operation that uses it. +// +// Linux ANCHORS. /proc/self/fd// starts the walk at the inode the +// descriptor holds, so a parent renamed away cannot be traversed at all and the +// guard is a no-op — there is nothing left to verify. +// +// macOS VERIFIES. It resolves lexically, so before and after every operation it +// proves that each element of the path chain still names the exact inode being +// held for it. That DETECTS a swapped parent and aborts; it does not make the +// swap impossible. A swap landing inside the window is caught by the trailing +// check, after the fact, rather than being unreachable. The check runs after a +// failure too, because a verdict observed through a chain that has since changed +// is not a verdict. +const LINUX_ANCHORING = { + childPath(dirHandle, childName) { + return descriptorPath(dirHandle.fd, childName); + }, + verified(holders, run) { + return run(); + }, + descriptorMatchesChild(fd, expectedPath) { + return fs.realpathSync.native(descriptorPath(fd)) === expectedPath; + }, + parentStillResolves(parentHandle) { + return fs.realpathSync.native(descriptorPath(parentHandle.fd)) === parentHandle.expectedPath; + }, + verifyAbsentChild(guard) { + if (absentChildIsPresent(guard.ref)) { + throw new Error(`${guard.repoPath} appeared before evidence materialization completed`); + } + }, +}; + +const DARWIN_ANCHORING = { + childPath(dirHandle, childName) { + return path.join(dirHandle.expectedPath, childName); + }, + verified(holders, run) { + const list = Array.isArray(holders) ? holders : [holders]; + const proveChain = () => { + for (const holder of list) { + verifyPinnedDescriptors(holder); + verifyLexicalChain(holder); + } + }; + proveChain(); + let value; + try { + value = run(); + } catch (error) { + proveChain(); + throw error; + } + proveChain(); + return value; + }, + descriptorMatchesChild(fd, _expectedPath, childStat) { + // There is no live fd-to-path oracle on macOS (F_GETPATH is a name-cache + // snapshot, not an anchor), so escape is decided the other way round: the + // name was just resolved under a verified chain, and the descriptor opened + // from it counts only if it is that same inode. + const opened = fs.fstatSync(fd, { bigint: true }); + return ( + opened.isDirectory() && stableDirectoryIdentity(opened) === stableDirectoryIdentity(childStat) + ); + }, + parentStillResolves(parentHandle) { + // Both halves are needed: a directory renamed away keeps its inode, so the + // descriptors alone still match and only the lexical half notices it moved. + try { + verifyPinnedDescriptors(parentHandle); + verifyLexicalChain(parentHandle); + } catch { + return false; + } + return true; + }, + verifyAbsentChild(guard) { + let present; + try { + present = DARWIN_ANCHORING.verified(guard.handle, () => absentChildIsPresent(guard.ref)); + } catch (error) { + // A chain that no longer holds makes the absence verdict meaningless, and + // the caller reports that as the anchor changing rather than as a stray + // parent-descriptor error. Linux cannot reach this: its guard is a no-op. + throw new Error( + `Absence anchor changed for ${guard.repoPath}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + if (present) { + throw new Error(`${guard.repoPath} appeared before evidence materialization completed`); + } + }, +}; + +const ANCHORING_BACKENDS = new Map([ + ['linux', LINUX_ANCHORING], + ['darwin', DARWIN_ANCHORING], +]); + +function anchoringBackend() { + const backend = ANCHORING_BACKENDS.get(process.platform); + if (!backend) { + // requireDescriptorAnchoring normally refuses first; this is the same answer + // from the other side, so an unsupported platform can never fall through to + // whichever backend happened to be the ternary's default. + throw new Error( + `No generated-plan anchoring backend for ${process.platform}; refusing an unanchored write`, + ); + } + return backend; +} + +// Open, fstat, compare, close on mismatch. The descriptor never escapes this +// function unless it refers to the inode the caller already verified by name, so +// a lexical open that landed anywhere else cannot be used by accident. On Linux +// the comparison passes trivially — the /proc walk already resolved from the +// held parent — and costs one fstat to keep the guarantee structural rather than +// dependent on which backend is in play. +function adoptVerifiedFile(ref, expectedStat, flags) { + const fd = openVerifiedFile(ref.path, flags); + let opened; try { - return fs.lstatSync(absolute, { bigint: true }); + opened = fs.fstatSync(fd, { bigint: true }); + } catch (error) { + fs.closeSync(fd); + throw error; + } + if (stableFileIdentity(opened) !== stableFileIdentity(expectedStat)) { + fs.closeSync(fd); + return null; + } + return fd; +} + +function absentChildIsPresent(ref) { + try { + fs.lstatSync(ref.path, { bigint: true }); + } catch (error) { + if (error?.code === 'ENOENT') return false; + throw error; + } + return true; +} + +// The operations. Each is the same on both platforms; only the guard differs. +function lstatChild(ref) { + return anchoringBackend().verified(ref.dir, () => fs.lstatSync(ref.path, { bigint: true })); +} + +function openChildRead(ref, flags, expectedStat) { + return anchoringBackend().verified(ref.dir, () => { + const fd = adoptVerifiedFile(ref, expectedStat, flags); + if (fd === null) { + throw new Error(`${ref.name} was replaced between its verified stat and its no-follow open`); + } + return fd; + }); +} + +function createChild(ref, flags, mode) { + // O_CREAT|O_EXCL|O_NOFOLLOW is atomic at the leaf, so the only thing the guard + // has to cover is which directory the leaf landed in. + return anchoringBackend().verified(ref.dir, () => openVerifiedFile(ref.path, flags, mode)); +} + +function mkdirChild(ref, mode) { + anchoringBackend().verified(ref.dir, () => fs.mkdirSync(ref.path, { mode })); +} + +function publishNoReplace(sourceRef, destinationRef) { + return anchoringBackend().verified([sourceRef.dir, destinationRef.dir], () => + linkNoReplace(sourceRef.path, destinationRef.path), + ); +} + +// The single place a name becomes a path, and therefore the right place to +// enforce that a name is one ordinary component. +// +// A trailing separator is the sharp edge here, not a tidiness concern: +// open(path, O_NOFOLLOW) FOLLOWS a symlink when path ends in "/" — the trap +// behind CVE-2026-39822 / golang/go#79005, which let os.Root escape its own +// root. path.join preserves that trailing slash, so a component carrying one +// would turn every no-follow open in this file into a following one. +// normalizeRepoPath already rejects such components upstream; this is the +// chokepoint that makes it true for every caller, including the generated +// temporary and vault names that never pass through it. +function anchoredChild(dirHandle, childName) { + if ( + typeof childName !== 'string' || + childName === '' || + childName === '.' || + childName === '..' || + childName.includes('/') || + childName.includes('\\') || + childName.includes('\0') + ) { + throw new Error(`Refusing to resolve ${JSON.stringify(childName)} as a single path component`); + } + return { + dir: dirHandle, + name: childName, + path: anchoringBackend().childPath(dirHandle, childName), + }; +} + +function lstatAnchoredOptional(ref) { + try { + return lstatChild(ref); } catch (error) { if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return null; throw error; @@ -1063,39 +1381,37 @@ function openPlanParent( { createMissing = true, purpose = 'Generated-plan' } = {}, ) { requireDescriptorAnchoring(); - const flags = - fs.constants.O_RDONLY | - fs.constants.O_DIRECTORY | - fs.constants.O_NOFOLLOW | - (fs.constants.O_CLOEXEC ?? 0); + // Root-first and index-aligned with `chain`: verifyPinnedDescriptors relies on + // that, and the descriptors are what pin each recorded inode against reuse. const descriptors = []; try { - let currentFd = fs.openSync(repo, flags); + let currentFd = openVerifiedDirectory(repo, ANCHORED_DIRECTORY_FLAGS); descriptors.push(currentFd); const rootStat = fs.fstatSync(currentFd, { bigint: true }); const chain = [{ expectedPath: repo, identity: stableDirectoryIdentity(rootStat) }]; + let currentHandle = { fd: currentFd, expectedPath: repo, chain, descriptors }; const traversed = []; for (const component of parentComponents) { traversed.push(component); - const anchoredChild = descriptorPath(currentFd, component); + const child = anchoredChild(currentHandle, component); let childStat; let created = false; try { - childStat = fs.lstatSync(anchoredChild, { bigint: true }); + childStat = lstatChild(child); } catch (error) { if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') throw error; if (!createMissing) { throw new Error(`${purpose} parent does not exist: ${traversed.join('/')}`); } - fs.mkdirSync(anchoredChild, { mode: 0o755 }); - childStat = fs.lstatSync(anchoredChild, { bigint: true }); + mkdirChild(child, 0o755); + childStat = lstatChild(child); created = true; } if (childStat.isSymbolicLink() || !childStat.isDirectory()) { throw new Error(`${purpose} parent is not a real directory: ${traversed.join('/')}`); } const parentFd = currentFd; - const childFd = fs.openSync(anchoredChild, flags); + const childFd = openVerifiedDirectory(child.path, ANCHORED_DIRECTORY_FLAGS); descriptors.push(childFd); currentFd = childFd; if (created) { @@ -1103,18 +1419,16 @@ function openPlanParent( fs.fsyncSync(parentFd); } const expected = path.join(repo, ...traversed); - const actual = fs.realpathSync(descriptorPath(currentFd)); - if (actual !== expected) { + if (!anchoringBackend().descriptorMatchesChild(currentFd, expected, childStat)) { throw new Error(`${purpose} parent escaped the repository: ${traversed.join('/')}`); } const openedStat = fs.fstatSync(currentFd, { bigint: true }); chain.push({ expectedPath: expected, identity: stableDirectoryIdentity(openedStat) }); + currentHandle = { fd: currentFd, expectedPath: expected, chain, descriptors }; } - const stat = fs.fstatSync(currentFd, { bigint: true }); return { descriptors, fd: currentFd, - identity: stableDirectoryIdentity(stat), expectedPath: path.join(repo, ...parentComponents), chain, }; @@ -1134,9 +1448,16 @@ function closeDescriptors(descriptors) { } } +// A handle's identity IS its chain leaf's identity. Storing it twice meant two +// fstats a line apart and a re-stamp helper to keep them agreeing; deriving it +// removes both. +function handleIdentity(handle) { + return handle.chain[handle.chain.length - 1].identity; +} + function resolveGitDirectory(repo) { const result = git(repo, ['rev-parse', '--absolute-git-dir']); - return fs.realpathSync(decodeUtf8(result.stdout, 'Git administrative directory').trim()); + return fs.realpathSync.native(decodeUtf8(result.stdout, 'Git administrative directory').trim()); } function openBackupVault(repo, { createMissing = true } = {}) { @@ -1147,9 +1468,12 @@ function openBackupVault(repo, { createMissing = true } = {}) { }); fs.fchmodSync(handle.fd, 0o700); fs.fsyncSync(handle.fd); - const stat = fs.fstatSync(handle.fd, { bigint: true }); - handle.identity = stableDirectoryIdentity(stat); - handle.chain[handle.chain.length - 1].identity = handle.identity; + // mode is part of every directory identity, so hardening the vault changes the + // identity the chain recorded for it; without this the next verification would + // reject the directory it just hardened. + handle.chain[handle.chain.length - 1].identity = stableDirectoryIdentity( + fs.fstatSync(handle.fd, { bigint: true }), + ); return { ...handle, gitDirectory }; } @@ -1157,33 +1481,28 @@ function validatePlanParent(parentHandle) { const descriptorStat = fs.fstatSync(parentHandle.fd, { bigint: true }); if ( !descriptorStat.isDirectory() || - stableDirectoryIdentity(descriptorStat) !== parentHandle.identity + stableDirectoryIdentity(descriptorStat) !== handleIdentity(parentHandle) ) { throw new Error('Generated-plan parent descriptor changed during the write'); } - const descriptorRealPath = fs.realpathSync(descriptorPath(parentHandle.fd)); - if (descriptorRealPath !== parentHandle.expectedPath) { + if (!anchoringBackend().parentStillResolves(parentHandle)) { throw new Error('Generated-plan parent moved or was replaced during the write'); } - for (const item of parentHandle.chain) { - const lexicalStat = fs.lstatSync(item.expectedPath, { bigint: true }); - if ( - lexicalStat.isSymbolicLink() || - !lexicalStat.isDirectory() || - stableDirectoryIdentity(lexicalStat) !== item.identity - ) { - throw new Error('Generated-plan lexical parent no longer matches its directory descriptor'); - } - } + // Both halves come from the shared helpers rather than being restated here: an + // earlier hand-copy of the lexical loop lost verifyLexicalChain's ENOENT/ENOTDIR + // translation, so a renamed parent could surface a raw errno from a function + // with a dozen call sites. + verifyPinnedDescriptors(parentHandle); + verifyLexicalChain(parentHandle); } function inspectPlanDestination( - finalPath, + finalRef, { replace, expectedIdentity, mustBeAbsent = false } = {}, ) { let stat; try { - stat = fs.lstatSync(finalPath, { bigint: true }); + stat = lstatChild(finalRef); } catch (error) { if (error?.code === 'ENOENT') { if (expectedIdentity) throw new Error('Generated plan disappeared during the write'); @@ -1201,19 +1520,17 @@ function inspectPlanDestination( if (expectedIdentity && identity !== expectedIdentity) { throw new Error('Generated plan changed during the write'); } - return identity; + return stat; } -function openExistingPlanDestination(finalPath, replace) { - const identity = inspectPlanDestination(finalPath, { replace }); - if (identity === null) { +function openExistingPlanDestination(finalRef, replace) { + const stat = inspectPlanDestination(finalRef, { replace }); + if (stat === null) { if (replace) throw new Error('Deepen mode requires an existing generated plan to replace'); return { fd: undefined, identity: null, stableIdentity: null }; } - const fd = fs.openSync( - finalPath, - fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_CLOEXEC ?? 0), - ); + const identity = statIdentity(stat); + const fd = openChildRead(finalRef, VERIFIED_READ_FLAGS, stat); try { const opened = fs.fstatSync(fd, { bigint: true }); if (!opened.isFile() || statIdentity(opened) !== identity) { @@ -1264,8 +1581,8 @@ function hashOpenFile(fd, label) { }; } -function validateCommittedPlan(finalPath, tempFd, expectedTemp, testHooks) { - const before = fs.lstatSync(finalPath, { bigint: true }); +function validateCommittedPlan(finalRef, tempFd, expectedTemp, testHooks) { + const before = lstatChild(finalRef); if ( before.isSymbolicLink() || !before.isFile() || @@ -1273,19 +1590,16 @@ function validateCommittedPlan(finalPath, tempFd, expectedTemp, testHooks) { ) { throw new Error('Generated-plan destination failed its first post-write identity check'); } - const finalFd = fs.openSync( - finalPath, - fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_CLOEXEC ?? 0), - ); + const finalFd = openChildRead(finalRef, VERIFIED_READ_FLAGS, before); try { const opened = fs.fstatSync(finalFd, { bigint: true }); if (!opened.isFile() || stableFileIdentity(opened) !== expectedTemp.identity) { throw new Error('Generated-plan destination changed while its no-follow descriptor opened'); } - testHooks?.afterFinalOpen?.({ fd: finalFd, finalPath }); + testHooks?.afterFinalOpen?.({ fd: finalFd, finalPath: finalRef.path }); const committedViaTemp = hashOpenFile(tempFd, 'generated-plan committed file'); const committedViaPath = hashOpenFile(finalFd, 'generated-plan destination descriptor'); - const after = fs.lstatSync(finalPath, { bigint: true }); + const after = lstatChild(finalRef); const openedAfter = fs.fstatSync(finalFd, { bigint: true }); if ( after.isSymbolicLink() || @@ -1320,22 +1634,19 @@ function copyOpenFile(sourceFd, destinationFd, label) { return after; } -function openVerifiedPathFile(absolute, label) { - const before = fs.lstatSync(absolute, { bigint: true }); +function openVerifiedAnchoredFile(ref, label, knownStat) { + const before = knownStat ?? lstatChild(ref); if (before.isSymbolicLink() || !before.isFile()) { throw new Error(`${label} is not a regular no-follow file`); } - const fd = fs.openSync( - absolute, - fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_CLOEXEC ?? 0), - ); + const fd = openChildRead(ref, VERIFIED_READ_FLAGS, before); try { const opened = fs.fstatSync(fd, { bigint: true }); if (!opened.isFile() || stableFileIdentity(opened) !== stableFileIdentity(before)) { throw new Error(`${label} changed while its descriptor opened`); } const layer = hashOpenFile(fd, label); - const after = fs.lstatSync(absolute, { bigint: true }); + const after = lstatChild(ref); if (after.isSymbolicLink() || !after.isFile() || stableFileIdentity(after) !== layer.identity) { throw new Error(`${label} changed after verification`); } @@ -1358,10 +1669,10 @@ export function readPlanSafely({ repo: repoInput, generatedPlanPath, testHooks } let fd; try { validatePlanParent(parentHandle); - const finalPath = descriptorPath(parentHandle.fd, finalName); + const finalRef = anchoredChild(parentHandle, finalName); let before; try { - before = fs.lstatSync(finalPath, { bigint: true }); + before = lstatChild(finalRef); } catch (error) { if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') { throw new Error(`Loaded plan does not exist: ${generatedPlan}`); @@ -1371,15 +1682,12 @@ export function readPlanSafely({ repo: repoInput, generatedPlanPath, testHooks } if (before.isSymbolicLink() || !before.isFile()) { throw new Error('Loaded plan must be a regular file, never a symlink'); } - fd = fs.openSync( - finalPath, - fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_CLOEXEC ?? 0), - ); + fd = openChildRead(finalRef, VERIFIED_READ_FLAGS, before); const opened = fs.fstatSync(fd, { bigint: true }); if (!opened.isFile() || statIdentity(opened) !== statIdentity(before)) { throw new Error('Loaded plan changed while its no-follow descriptor opened'); } - testHooks?.afterPlanOpen?.({ fd, finalPath }); + testHooks?.afterPlanOpen?.({ fd, finalPath: finalRef.path }); const chunks = []; let total = 0; const buffer = Buffer.allocUnsafe(64 * 1024); @@ -1394,7 +1702,7 @@ export function readPlanSafely({ repo: repoInput, generatedPlanPath, testHooks } decodeUtf8(contents, 'loaded plan'); const after = fs.fstatSync(fd, { bigint: true }); assertStableIdentity(opened, after, 'loaded plan'); - const pathAfter = fs.lstatSync(finalPath, { bigint: true }); + const pathAfter = lstatChild(finalRef); if ( pathAfter.isSymbolicLink() || !pathAfter.isFile() || @@ -1419,24 +1727,22 @@ function artifactGitPath(name) { return `gitnexus-plan-backups/${name}`; } -function verifyVaultArtifactFromFreshRoot(repo, gitPath, expectedLayer) { - const components = gitPath.split('/'); - if (components.length !== 2 || components[0] !== 'gitnexus-plan-backups') { - throw new Error(`Invalid Git-admin artifact path: ${gitPath}`); - } +function verifyVaultArtifactFromFreshRoot(repo, name, expectedLayer) { const freshVault = openBackupVault(repo, { createMissing: false }); try { validatePlanParent(freshVault); - const opened = openVerifiedPathFile( - descriptorPath(freshVault.fd, components[1]), - `Git-admin artifact ${gitPath}`, + const opened = openVerifiedAnchoredFile( + anchoredChild(freshVault, name), + `Git-admin artifact ${artifactGitPath(name)}`, ); try { if ( opened.layer.identity !== expectedLayer.identity || opened.layer.digest !== expectedLayer.digest ) { - throw new Error(`Git-admin artifact changed before fresh-root verification: ${gitPath}`); + throw new Error( + `Git-admin artifact changed before fresh-root verification: ${artifactGitPath(name)}`, + ); } } finally { fs.closeSync(opened.fd); @@ -1449,16 +1755,8 @@ function verifyVaultArtifactFromFreshRoot(repo, gitPath, expectedLayer) { function createVaultCopyFromFd(repo, vault, sourceFd, role) { validatePlanParent(vault); const name = `.gitnexus-plan-${role}-${process.pid}-${randomBytes(16).toString('hex')}.bak`; - const absolute = descriptorPath(vault.fd, name); - const destinationFd = fs.openSync( - absolute, - fs.constants.O_RDWR | - fs.constants.O_CREAT | - fs.constants.O_EXCL | - fs.constants.O_NOFOLLOW | - (fs.constants.O_CLOEXEC ?? 0), - 0o600, - ); + const artifact = anchoredChild(vault, name); + const destinationFd = createChild(artifact, VERIFIED_CREATE_FLAGS, 0o600); let destination; try { const sourceStat = copyOpenFile(sourceFd, destinationFd, role); @@ -1469,7 +1767,7 @@ function createVaultCopyFromFd(repo, vault, sourceFd, role) { if (source.size !== destination.size || source.digest !== destination.digest) { throw new Error(`${role} vault copy does not match its held source descriptor`); } - const pathStat = fs.lstatSync(absolute, { bigint: true }); + const pathStat = lstatChild(artifact); if ( pathStat.isSymbolicLink() || !pathStat.isFile() || @@ -1481,24 +1779,15 @@ function createVaultCopyFromFd(repo, vault, sourceFd, role) { } finally { fs.closeSync(destinationFd); } - const gitPath = artifactGitPath(name); - verifyVaultArtifactFromFreshRoot(repo, gitPath, destination); - return { role, gitPath, layer: destination }; + verifyVaultArtifactFromFreshRoot(repo, name, destination); + return { role, gitPath: artifactGitPath(name), layer: destination }; } function createVaultCopyFromBytes(repo, vault, contents, role) { validatePlanParent(vault); const name = `.gitnexus-plan-${role}-${process.pid}-${randomBytes(16).toString('hex')}.bak`; - const absolute = descriptorPath(vault.fd, name); - const fd = fs.openSync( - absolute, - fs.constants.O_RDWR | - fs.constants.O_CREAT | - fs.constants.O_EXCL | - fs.constants.O_NOFOLLOW | - (fs.constants.O_CLOEXEC ?? 0), - 0o600, - ); + const artifact = anchoredChild(vault, name); + const fd = createChild(artifact, VERIFIED_CREATE_FLAGS, 0o600); let layer; try { writeAll(fd, contents); @@ -1508,7 +1797,7 @@ function createVaultCopyFromBytes(repo, vault, contents, role) { if (layer.size !== BigInt(contents.length) || layer.digest !== sha256(contents)) { throw new Error(`${role} vault copy does not match the intended plan bytes`); } - const pathStat = fs.lstatSync(absolute, { bigint: true }); + const pathStat = lstatChild(artifact); if ( pathStat.isSymbolicLink() || !pathStat.isFile() || @@ -1520,32 +1809,31 @@ function createVaultCopyFromBytes(repo, vault, contents, role) { } finally { fs.closeSync(fd); } - const gitPath = artifactGitPath(name); - verifyVaultArtifactFromFreshRoot(repo, gitPath, layer); - return { role, gitPath, layer }; + verifyVaultArtifactFromFreshRoot(repo, name, layer); + return { role, gitPath: artifactGitPath(name), layer }; } function movePathToVault(repo, sourceHandle, sourceName, vault, role) { - const source = descriptorPath(sourceHandle.fd, sourceName); - if (!lstatOptional(source)) return null; + const source = anchoredChild(sourceHandle, sourceName); + if (!lstatAnchoredOptional(source)) return null; const name = `.gitnexus-plan-${role}-${process.pid}-${randomBytes(16).toString('hex')}.bak`; - const destination = descriptorPath(vault.fd, name); - const moved = atomicMoveNoReplace( - externalDescriptorPath(sourceHandle.fd, sourceName), - externalDescriptorPath(vault.fd, name), - ); + const destination = anchoredChild(vault, name); + const moved = publishNoReplace(source, destination); if (!moved) throw new Error(`${role} preservation destination unexpectedly exists`); fs.fsyncSync(sourceHandle.fd); if (vault.fd !== sourceHandle.fd) fs.fsyncSync(vault.fd); - const sourceAfter = lstatOptional(source); - const destinationAfter = lstatOptional(destination); + const sourceAfter = lstatAnchoredOptional(source); + const destinationAfter = lstatAnchoredOptional(destination); if (sourceAfter || !destinationAfter) { throw new Error(`${role} could not be atomically moved into the Git-admin vault`); } - const opened = openVerifiedPathFile(destination, `${role} Git-admin artifact`); - const gitPath = artifactGitPath(name); - verifyVaultArtifactFromFreshRoot(repo, gitPath, opened.layer); - return { role, gitPath, layer: opened.layer, fd: opened.fd }; + const opened = openVerifiedAnchoredFile( + destination, + `${role} Git-admin artifact`, + destinationAfter, + ); + verifyVaultArtifactFromFreshRoot(repo, name, opened.layer); + return { role, gitPath: artifactGitPath(name), layer: opened.layer, fd: opened.fd }; } function formatPreservedArtifacts(artifacts) { @@ -1600,10 +1888,10 @@ export function writePlanSafely({ const finalName = components.pop(); let parentHandle; let vaultHandle; - let tempPath; + let tempRef; let tempName; let tempFd; - let finalPath; + let finalRef; let expectedTemp; let originalDestination; let priorBackup; @@ -1611,7 +1899,6 @@ export function writePlanSafely({ try { parentHandle = openPlanParent(repo, components); vaultHandle = openBackupVault(repo); - resolveAtomicMover(); const parentDevice = fs.fstatSync(parentHandle.fd, { bigint: true }).dev; const vaultDevice = fs.fstatSync(vaultHandle.fd, { bigint: true }).dev; if (parentDevice !== vaultDevice) { @@ -1622,19 +1909,11 @@ export function writePlanSafely({ testHooks?.afterParentOpen?.({ fd: parentHandle.fd, path: parentHandle.expectedPath }); validatePlanParent(parentHandle); validatePlanParent(vaultHandle); - finalPath = descriptorPath(parentHandle.fd, finalName); - originalDestination = openExistingPlanDestination(finalPath, shouldReplace); + finalRef = anchoredChild(parentHandle, finalName); + originalDestination = openExistingPlanDestination(finalRef, shouldReplace); tempName = `.gitnexus-plan-${process.pid}-${randomBytes(16).toString('hex')}.tmp`; - tempPath = descriptorPath(parentHandle.fd, tempName); - tempFd = fs.openSync( - tempPath, - fs.constants.O_RDWR | - fs.constants.O_CREAT | - fs.constants.O_EXCL | - fs.constants.O_NOFOLLOW | - (fs.constants.O_CLOEXEC ?? 0), - 0o600, - ); + tempRef = anchoredChild(parentHandle, tempName); + tempFd = createChild(tempRef, VERIFIED_CREATE_FLAGS, 0o600); writeAll(tempFd, contents); fs.fchmodSync(tempFd, 0o644); fs.fsyncSync(tempFd); @@ -1646,12 +1925,12 @@ export function writePlanSafely({ testHooks?.beforeRename?.({ fd: parentHandle.fd, path: parentHandle.expectedPath, - tempPath, + tempPath: tempRef.path, }); validatePlanParent(parentHandle); validatePlanParent(vaultHandle); validateOpenPlanDestination(originalDestination); - const tempPathStat = fs.lstatSync(tempPath, { bigint: true }); + const tempPathStat = lstatChild(tempRef); const currentTemp = hashOpenFile(tempFd, 'generated-plan temporary file'); if ( tempPathStat.isSymbolicLink() || @@ -1664,7 +1943,7 @@ export function writePlanSafely({ } if (shouldReplace) { - testHooks?.beforeBackupMove?.({ fd: parentHandle.fd, finalPath }); + testHooks?.beforeBackupMove?.({ fd: parentHandle.fd, finalPath: finalRef.path }); const originalLayer = hashOpenFile(originalDestination.fd, 'prior generated plan'); if (originalLayer.digest !== expectedDigest) { throw new Error( @@ -1673,7 +1952,7 @@ export function writePlanSafely({ } validatePlanParent(parentHandle); validateOpenPlanDestination(originalDestination); - inspectPlanDestination(finalPath, { + inspectPlanDestination(finalRef, { replace: true, expectedIdentity: originalDestination.identity, }); @@ -1691,20 +1970,20 @@ export function writePlanSafely({ ); throw new Error('Destination raced while the prior plan was moved into preservation'); } - if (lstatOptional(finalPath)) { + if (lstatAnchoredOptional(finalRef)) { throw new Error('Destination reappeared after the prior plan was preserved'); } } testHooks?.beforePublication?.({ fd: parentHandle.fd, - finalPath, - tempPath, + finalPath: finalRef.path, + tempPath: tempRef.path, replace: shouldReplace, }); validatePlanParent(parentHandle); validatePlanParent(vaultHandle); - const finalTempPathStat = fs.lstatSync(tempPath, { bigint: true }); + const finalTempPathStat = lstatChild(tempRef); const finalTemp = hashOpenFile(tempFd, 'generated-plan temporary file'); if ( finalTempPathStat.isSymbolicLink() || @@ -1715,19 +1994,25 @@ export function writePlanSafely({ ) { throw new Error('Generated-plan temporary path or content changed at publication'); } - atomicMoveNoReplace( - externalDescriptorPath(parentHandle.fd, tempName), - externalDescriptorPath(parentHandle.fd, finalName), - ); - if (lstatOptional(tempPath) || !lstatOptional(finalPath)) { + // link() reports the race itself; re-deriving that verdict from a later pair + // of stats would be both slower and weaker. + if (!publishNoReplace(tempRef, finalRef)) { throw new Error('Generated-plan publication was refused because the destination raced'); } + // link() creates a directory entry, so it needs the parent fsync that rename + // needed: the file's own bytes were fsynced through tempFd before this point, + // and this makes the name that now reaches them durable too. Skipping it is + // the step write-file-atomic omits and maildir, git and atomicwrites all + // mandate. + // + // Honest limitation: on macOS fsync is not a write barrier — the durable + // primitive there is fcntl(F_FULLFSYNC), which Node does not expose. A + // macOS plan write is therefore as durable as fsync makes it and no more. fs.fsyncSync(parentHandle.fd); - testHooks?.afterPublication?.({ fd: parentHandle.fd, finalPath }); - testHooks?.afterRename?.({ fd: parentHandle.fd, finalPath }); + testHooks?.afterPublication?.({ fd: parentHandle.fd, finalPath: finalRef.path }); validatePlanParent(parentHandle); validatePlanParent(vaultHandle); - validateCommittedPlan(finalPath, tempFd, expectedTemp, testHooks); + validateCommittedPlan(finalRef, tempFd, expectedTemp, testHooks); const receipt = { generated_plan_path: generatedPlan, bytes_written: contents.length }; if (priorBackup) receipt.prior_plan_backup_git_path = priorBackup.gitPath; return receipt; @@ -1848,6 +2133,11 @@ export function snapshotEvidence({ const headGuards = captureHeadGuards(repo); const dirty = initialDirty.records; const mutationGuards = []; + // Per-snapshot walk state: `absenceCache` owns every descriptor an absence + // anchor holds, deduplicated by repo-relative prefix and closed exactly once + // below; `guardedDirectories` keeps parent guarding to one stat per directory. + const absenceCache = new Map(); + const walkState = { absenceCache, guardedDirectories: new Set() }; try { testHooks?.afterAnchorCapture?.({ headCommit: head }); @@ -1862,7 +2152,9 @@ export function snapshotEvidence({ testHooks?.afterGitLayerLoad?.({ headCommit: head }); const globalEntries = [...dirty.values()] .filter((record) => record.path !== generatedPlan) - .map((record) => materializeRecord(repo, record, layers, mutationGuards, testHooks)); + .map((record) => + materializeRecord(repo, record, layers, mutationGuards, testHooks, walkState), + ); const citedEntries = [...normalizedCitations].sort(compareUtf8).map((repoPath) => { const status = dirty.get(repoPath) ?? { path: repoPath, @@ -1871,7 +2163,7 @@ export function snapshotEvidence({ rename_to: null, has_untracked: false, }; - const entry = materializeRecord(repo, status, layers, mutationGuards, testHooks); + const entry = materializeRecord(repo, status, layers, mutationGuards, testHooks, walkState); const present = Object.values(entry.object_kind).some((kind) => kind !== ABSENT); if (!present) entry.state = ABSENT; else if (entry.state === 'clean' && entry.object_kind.untracked !== ABSENT) { @@ -1906,21 +2198,13 @@ export function snapshotEvidence({ throw new Error(`${guard.absolute} changed before evidence materialization completed`); } } else if (guard.type === 'absence') { + // statIdentity is a strict superset of stableDirectoryIdentity on the + // same stat, so comparing both could only ever fire together. const parent = fs.fstatSync(guard.fd, { bigint: true }); - if ( - !parent.isDirectory() || - stableDirectoryIdentity(parent) !== guard.parentIdentity || - statIdentity(parent) !== guard.parentMutationIdentity - ) { + if (!parent.isDirectory() || statIdentity(parent) !== guard.parentMutationIdentity) { throw new Error(`Absence anchor changed for ${guard.repoPath}`); } - try { - fs.lstatSync(descriptorPath(guard.fd, guard.childName), { bigint: true }); - } catch (error) { - if (error?.code === 'ENOENT') continue; - throw error; - } - throw new Error(`${guard.repoPath} appeared before evidence materialization completed`); + anchoringBackend().verifyAbsentChild(guard); } } for (const guard of headGuards) verifyControlFile(guard); @@ -1955,12 +2239,10 @@ export function snapshotEvidence({ cited_path_manifest: citedEntries, }; } finally { - const closed = new Set(); - for (const guard of mutationGuards) { - if (guard.type !== 'absence' || closed.has(guard.fd)) continue; - closed.add(guard.fd); + // One entry per distinct anchored directory, so one close per descriptor. + for (const handle of absenceCache.values()) { try { - fs.closeSync(guard.fd); + fs.closeSync(handle.fd); } catch { // Preserve the primary snapshot result/error. } diff --git a/gitnexus/skills/gitnexus-refactoring.md b/gitnexus/skills/gitnexus-refactoring.md index 2dbb71ca0..9d63eb6e3 100644 --- a/gitnexus/skills/gitnexus-refactoring.md +++ b/gitnexus/skills/gitnexus-refactoring.md @@ -13,9 +13,32 @@ description: "Use when the user wants to rename, extract, split, move, or restru - "Move this to a new file" - Any task involving renaming, extracting, splitting, or restructuring code +## Bind the repository first + +Refactoring writes to disk. `rename` with `dry_run: false` edits files in +whichever repository was resolved, so binding identity here is a safety gate, +not bookkeeping. + +Call `list_repos {}` before the first tool call. With one indexed repository, +use the examples below as written. With more than one, pass `repo` on every +call: an omitted `repo` normally errors, but under an MCP policy with a +configured default it resolves to that default silently. If you cannot tell +which repository is meant, stop and ask. Never run `rename` with +`dry_run: false` until the preview in the same bound repository has been +reviewed — its returned `file_path` values show which checkout is about to be +written, so read them as a confirmation of identity. + +`list_repos` is paginated, so page with `offset: pagination.nextOffset` until +`hasMore` is false before concluding a repository is absent. + +`detect_changes` takes `worktree` when you are editing a linked worktree the +MCP server was not launched from; otherwise `git diff` runs in the wrong +checkout and reports nothing changed, which reads as a verified refactor. + ## Workflow ``` +0. list_repos {} → Bind repo (and worktree) 1. impact({target: "X", direction: "upstream"}) → Map all dependents 2. query({search_query: "X"}) → Find execution flows involving X 3. context({name: "X"}) → See all incoming/outgoing refs @@ -29,7 +52,9 @@ description: "Use when the user wants to rename, extract, split, move, or restru ### Rename Symbol ``` +- [ ] list_repos {} — bind repo; explicit repo when >1 indexed, ask if ambiguous - [ ] rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits +- [ ] Confirm the previewed file paths are in the bound repository/worktree - [ ] Review graph edits (high confidence) and text_search edits (review carefully) - [ ] If satisfied: rename({..., dry_run: false}) — apply edits - [ ] detect_changes() — verify only expected files changed @@ -39,6 +64,7 @@ description: "Use when the user wants to rename, extract, split, move, or restru ### Extract Module ``` +- [ ] list_repos {} — bind repo; explicit repo when >1 indexed, ask if ambiguous - [ ] context({name: target}) — see all incoming/outgoing refs - [ ] impact({target, direction: "upstream"}) — find all external callers - [ ] Define new module interface @@ -50,6 +76,7 @@ description: "Use when the user wants to rename, extract, split, move, or restru ### Split Function/Service ``` +- [ ] list_repos {} — bind repo; explicit repo when >1 indexed, ask if ambiguous - [ ] context({name: target}) — understand all callees - [ ] Group callees by responsibility - [ ] impact({target, direction: "upstream"}) — map callers to update @@ -64,7 +91,7 @@ description: "Use when the user wants to rename, extract, split, move, or restru **rename** — automated multi-file rename: ``` -rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true}) +rename({symbol_name: "validateUser", new_name: "authenticateUser", repo: "my-app", dry_run: true}) → 12 edits across 8 files → 10 graph edits (high confidence), 2 text_search edits (review) → Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}] @@ -73,7 +100,7 @@ rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true **impact** — map all dependents first: ``` -impact({target: "validateUser", direction: "upstream"}) +impact({target: "validateUser", repo: "my-app", direction: "upstream"}) → d=1: loginHandler, apiMiddleware, testUtils → Affected Processes: LoginFlow, TokenRefresh ``` @@ -87,6 +114,14 @@ detect_changes({scope: "all"}) → Risk: MEDIUM ``` +`partial: true` (a graph query failed) or `truncated: true` (the changed-symbol +listing was capped) means the result is short of the truth: a short or empty +list is not proof that only the expected files changed. Re-run it rather than +treat the refactor as verified. + +A wrong-worktree zero carries neither flag and is indistinguishable from a +clean verification, so confirm the diffed checkout is the one you edited. + **cypher** — custom reference queries: ```cypher @@ -102,20 +137,28 @@ RETURN caller.name, caller.filePath ORDER BY caller.filePath | Cross-area refs | Use detect_changes after to verify scope | | String/dynamic refs | query to find them | | External/public API | Version and deprecate properly | +| Same name in another indexed repo | Bind `repo`; verify previewed paths before applying | ## Example: Rename `validateUser` to `authenticateUser` ``` -1. rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true}) +0. list_repos {} + → total: 2 (my-app, billing-api) — both define validateUser, so bind explicitly + +1. rename({symbol_name: "validateUser", new_name: "authenticateUser", repo: "my-app", dry_run: true}) → 12 edits: 10 graph (safe), 2 text_search (review) → Files: validator.ts, login.ts, middleware.ts, config.json... 2. Review text_search edits (config.json: dynamic reference!) -3. rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false}) +3. rename({symbol_name: "validateUser", new_name: "authenticateUser", repo: "my-app", dry_run: false}) → Applied 12 edits across 8 files -4. detect_changes({scope: "all"}) +4. detect_changes({scope: "all", repo: "my-app"}) → Affected: LoginFlow, TokenRefresh → Risk: MEDIUM — run tests for these flows + Repository: my-app (/abs/path/my-app) Worktree: same Index: current ``` + +With a single indexed repository, step 0 returns `total: 1` and the `repo` +argument drops out of every call above. diff --git a/gitnexus/skills/gitnexus-work/SKILL.md b/gitnexus/skills/gitnexus-work/SKILL.md index 4f7856ea7..f9baab16a 100644 --- a/gitnexus/skills/gitnexus-work/SKILL.md +++ b/gitnexus/skills/gitnexus-work/SKILL.md @@ -216,7 +216,10 @@ Work through plan §7 step by step, in order. For each step: `detect_changes` → commit as one unbroken sequence from the repository root — interleaving other work between the gate and the commit is how the gate gets skipped. Unexpected - affected flows → investigate before committing, not after. + affected flows → investigate before committing, not after. A result + flagged `partial` (a graph query failed) or `truncated` (the symbol + listing was capped) blocks the commit the same way: the gate did not + see every changed symbol, so re-run it rather than read it as clean. A relationship-affecting implementation edit or commit invalidates the procedure's prior proof. The next step must perform the required inter-step diff --git a/gitnexus/skills/gitnexus-work/references/evidence-provenance.md b/gitnexus/skills/gitnexus-work/references/evidence-provenance.md index c686599da..3df5a046d 100644 --- a/gitnexus/skills/gitnexus-work/references/evidence-provenance.md +++ b/gitnexus/skills/gitnexus-work/references/evidence-provenance.md @@ -98,8 +98,11 @@ excluded. ## Safe existing-plan read contract -`read-plan` fails closed unless Linux `/proc/self/fd`, `O_DIRECTORY`, and -`O_NOFOLLOW` are available. It resolves the exact Git top-level, opens the +`read-plan` fails closed unless the host platform can resolve names against a +held directory descriptor: Linux `/proc/self/fd` with `O_DIRECTORY` and +`O_NOFOLLOW`, or macOS `O_DIRECTORY`/`O_NOFOLLOW`. Every other platform is +refused outright — an unverified read is not a degraded read, it is a different, +racy operation. It resolves the exact Git top-level, opens the repository root and every plan parent as held no-follow directory descriptors, rejects missing, symlink, non-directory, and escaping parents, and opens the leaf with `O_NOFOLLOW`. It reads at most 16 MiB from that held file descriptor, @@ -109,13 +112,17 @@ Neither Deepen nor work may parse bytes obtained before or outside this receipt. ## Safe generated-plan write contract -The writer fails closed unless Linux `/proc/self/fd`, `O_DIRECTORY`, -`O_NOFOLLOW`, and Python 3 with libc `renameat2(RENAME_NOREPLACE)` support are -available. Python may live in `/usr/local`, a Nix profile, or another absolute -PATH directory, but the helper accepts only a resolved executable and -containing directory owned by root or the current user and not writable by -group/other. The resolved executable is opened without following links and -invoked through that held descriptor. Relative PATH entries are ignored. The plan parent and the +The writer fails closed unless the host platform offers `O_DIRECTORY` and +`O_NOFOLLOW`, plus `/proc/self/fd` on Linux. It spawns no interpreter and loads +no native code: publication is `link(2)`, which is atomic, fails `EEXIST` when +the destination name is taken, and refuses a symlinked destination without +following it — the same no-replace guarantee `renameat2(RENAME_NOREPLACE)` and +`renameatx_np(RENAME_EXCL)` provide, available through `fs.linkSync` on every +supported platform. The temporary name is unlinked once the link succeeds; the +published file is the same inode the writer created and verified, so every +identity check downstream holds by construction. A link that succeeds followed +by an unlink that fails leaves the plan published and is reported as success, +because it is one. The plan parent and the repository's Git-admin directory must also share a filesystem. It resolves the target repository's exact Git top-level, opens that root and every destination parent as held no-follow directory descriptors, creates missing @@ -128,15 +135,45 @@ The writer creates a random exclusive temporary file relative to the held final parent descriptor and keeps its no-follow descriptor open. It writes and flushes the bytes, binds the temporary name to the opened inode, and hashes the open file before publication. Immediately before publication it revalidates -the parent and the temporary path, inode, size, and digest. Publication uses an -atomic no-replace move relative to the held directory descriptor. Initial mode -therefore cannot overwrite a destination that appears after the absent check. +the parent and the temporary path, inode, size, and digest. Publication links +the temporary name to the destination relative to the held directory +descriptor, which fails rather than replaces if the destination is taken. +Initial mode therefore cannot overwrite a destination that appears after the +absent check. The writer then flushes the directory and revalidates the committed path by opening it with `O_NOFOLLOW`, hashing both the original temporary fd and the path-bound fd, and performing a second descriptor-anchored path identity check after hashing. A detected mutation or replacement aborts instead of accepting mixed-era output. +### Linux anchors, macOS verifies + +The two platforms reach the same destination by different proofs, and the +difference is real enough to state rather than smooth over. + +On Linux every name resolves through `/proc/self/fd//`, a magic link +the kernel resolves against the inode the descriptor already holds. The names +above it are never re-walked, so an attacker who renames a parent between the +check and the use cannot redirect the operation. The race is impossible, not +merely detected. + +macOS has no such path. `/dev/fd/` is a devfs node, not a magic link: it can +be opened, but nothing can be resolved through it. `open("/dev/fd//child")` +returns `ENOENT`, and `realpath` of it returns `/dev/fd/` rather than the +directory's path — measured on macOS 26, not inferred. Node exposes no `openat`, +no `dir_fd` parameter, and no FFI, so on macOS the writer resolves names +lexically with `O_NOFOLLOW` at every component, holds an open descriptor on +every directory in the chain for the whole operation, and proves before *and* +after each step that the chain still names exactly the inodes it is holding. +Holding the descriptors is what makes the recorded inode numbers trustworthy: +an open descriptor pins its inode, so a freed number cannot be recycled beneath +the walk. + +What that buys is detection rather than prevention. A parent swapped inside the +window between a check and its use is caught by the check that follows, and the +operation aborts having written nothing — but on Linux it could not have +happened at all. No published byte escapes verification on either platform. + `--replace` accepts only a pre-existing regular file and is reserved for Deepen; without it, accidental overwrite is rejected. It also requires the exact canonical `generated_plan_path` and `plan_digest` from the same session's diff --git a/gitnexus/skills/gitnexus-work/scripts/evidence-provenance.mjs b/gitnexus/skills/gitnexus-work/scripts/evidence-provenance.mjs index 181d2120b..793fe4cd8 100644 --- a/gitnexus/skills/gitnexus-work/scripts/evidence-provenance.mjs +++ b/gitnexus/skills/gitnexus-work/scripts/evidence-provenance.mjs @@ -479,11 +479,11 @@ function resolveOwnGitTopLevel(absolute) { if (result.status !== 0) return null; let topLevel; try { - topLevel = fs.realpathSync(decodeUtf8(result.stdout, 'nested repository root').trim()); + topLevel = fs.realpathSync.native(decodeUtf8(result.stdout, 'nested repository root').trim()); } catch { return null; } - return topLevel === fs.realpathSync(absolute) ? topLevel : null; + return topLevel === fs.realpathSync.native(absolute) ? topLevel : null; } function readOwnGitlinkHead(absolute) { @@ -616,17 +616,30 @@ function filesystemObject(absolute, expectedKind, mutationGuards, testHooks) { throw new Error(`Unsupported filesystem object at ${absolute}`); } -function guardPathParents(repo, repoPath, mutationGuards) { +// Every dirty path re-walks its own parents, and dirty paths overwhelmingly +// share them — the repository root is re-stat'ed once per path. `guarded` is +// per-snapshot and remembers which absolute directories already carry a guard, +// so each distinct directory is stat'ed and guarded exactly once. +// +// Keeping the first-seen identity is the conservative choice: verifyGuards +// re-checks every guard against the filesystem at the end, so a directory that +// changes after it was guarded still fails there. Skipping a re-stat cannot hide +// a change; it only avoids recording the same directory twice. +function guardPathParents(repo, repoPath, mutationGuards, guarded) { const components = repoPath.split('/'); let current = repo; - const rootStat = fs.lstatSync(repo, { bigint: true }); - mutationGuards.push({ - type: 'directory', - absolute: repo, - identity: stableDirectoryIdentity(rootStat), - }); + if (!guarded.has(repo)) { + guarded.add(repo); + mutationGuards.push({ + type: 'directory', + absolute: repo, + identity: stableDirectoryIdentity(fs.lstatSync(repo, { bigint: true })), + }); + } for (const component of components.slice(0, -1)) { current = path.join(current, component); + // Already proved a real directory and already guarded on an earlier path. + if (guarded.has(current)) continue; let stat; try { stat = fs.lstatSync(current, { bigint: true }); @@ -638,6 +651,7 @@ function guardPathParents(repo, repoPath, mutationGuards) { throw new Error(`Refusing to traverse symlink parent for ${repoPath}`); } if (!stat.isDirectory()) return; + guarded.add(current); mutationGuards.push({ type: 'directory', absolute: current, @@ -646,81 +660,153 @@ function guardPathParents(repo, repoPath, mutationGuards) { } } -function recordAnchoredAbsence(repo, repoPath, mutationGuards) { - requireDescriptorAnchoring(); - const flags = - fs.constants.O_RDONLY | - fs.constants.O_DIRECTORY | - fs.constants.O_NOFOLLOW | - (fs.constants.O_CLOEXEC ?? 0); - const descriptors = []; - let retainedFd; - try { - let currentFd = fs.openSync(repo, flags); - descriptors.push(currentFd); - const components = repoPath.split('/'); - for (let index = 0; index < components.length; index += 1) { - const component = components[index]; - const child = descriptorPath(currentFd, component); - let childStat; - try { - childStat = fs.lstatSync(child, { bigint: true }); - } catch (error) { - if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') throw error; - const parentStat = fs.fstatSync(currentFd, { bigint: true }); - if (!parentStat.isDirectory()) { - throw new Error(`Absence parent is no longer a directory for ${repoPath}`); - } - retainedFd = currentFd; - mutationGuards.push({ - type: 'absence', - fd: retainedFd, - childName: component, - repoPath, - parentIdentity: stableDirectoryIdentity(parentStat), - parentMutationIdentity: statIdentity(parentStat), - }); - for (const fd of descriptors) { - if (fd !== retainedFd) fs.closeSync(fd); - } - return; - } - if (index === components.length - 1) { - throw new Error(`${repoPath} appeared while its absence was being anchored`); - } - if (childStat.isSymbolicLink() || !childStat.isDirectory()) { - throw new Error(`Refusing a non-directory parent while anchoring absence for ${repoPath}`); - } - const nextFd = fs.openSync(child, flags); - descriptors.push(nextFd); - currentFd = nextFd; - } - throw new Error(`Could not anchor absence for ${repoPath}`); - } catch (error) { - for (const fd of descriptors) { - if (fd === retainedFd) continue; - try { - fs.closeSync(fd); - } catch { - // Preserve the primary absence-anchoring error. - } - } - throw error; +// A bound, not a bug: the absence cache deduplicates correctly and leaks nothing, +// but citedPaths is caller-supplied and unbounded, so a pathological snapshot +// could hold more descriptors than the process is allowed (macOS +// kern.maxfilesperproc is 24576). The peak precedes a `git` spawn, so exhaustion +// would surface as a git failure misreported as evidence instability. +// +// Refuse rather than evict: closing a cached descriptor would silently break the +// pinned chain of an absence guard that was already recorded against it, which is +// exactly the inode-recycling hole the pins exist to close. +const ABSENCE_ANCHOR_LIMITS = Object.freeze({ maxPinnedDirectories: 4096 }); + +// Every no-follow read and every exclusive create in this file uses one of these +// two, so a change lands in one place rather than in seven. +const VERIFIED_READ_FLAGS = + fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_CLOEXEC ?? 0); +const VERIFIED_CREATE_FLAGS = + fs.constants.O_RDWR | + fs.constants.O_CREAT | + fs.constants.O_EXCL | + fs.constants.O_NOFOLLOW | + (fs.constants.O_CLOEXEC ?? 0); + +function requireAbsenceAnchorCapacity(cache) { + if (cache.size >= ABSENCE_ANCHOR_LIMITS.maxPinnedDirectories) { + throw new Error( + `Absence anchoring exceeds ${ABSENCE_ANCHOR_LIMITS.maxPinnedDirectories} pinned directories`, + ); } } -function materializeRecord(repo, statusRecord, layers, mutationGuards, testHooks) { +const ANCHORED_DIRECTORY_FLAGS = + fs.constants.O_RDONLY | + fs.constants.O_DIRECTORY | + fs.constants.O_NOFOLLOW | + (fs.constants.O_CLOEXEC ?? 0); + +// Every absence receipt is verified long after its walk returns, so the chain +// that produced it has to stay pinned until the snapshot ends — an unpinned inode +// number can be recycled by a replacement directory that then reproduces the +// recorded identity exactly. Absent cited paths overwhelmingly share prefixes, so +// the walked directories are cached per snapshot and keyed by repo-relative +// prefix: one open descriptor and one anchored walk per distinct directory rather +// than per path. snapshotEvidence owns every descriptor in this cache and closes +// each exactly once; guards only borrow them for verification. +function anchoredAbsenceRoot(repo, cache) { + const cached = cache.get(''); + if (cached) return cached; + requireAbsenceAnchorCapacity(cache); + const fd = openVerifiedDirectory(repo, ANCHORED_DIRECTORY_FLAGS); + const handle = { + fd, + expectedPath: repo, + chain: [ + { expectedPath: repo, identity: stableDirectoryIdentity(fs.fstatSync(fd, { bigint: true })) }, + ], + descriptors: [fd], + }; + cache.set('', handle); + return handle; +} + +function recordAnchoredAbsence(repo, repoPath, mutationGuards, cache) { + requireDescriptorAnchoring(); + const components = repoPath.split('/'); + let handle = anchoredAbsenceRoot(repo, cache); + let prefix = ''; + for (let index = 0; index < components.length; index += 1) { + const component = components[index]; + const isFinal = index === components.length - 1; + prefix = prefix === '' ? component : `${prefix}/${component}`; + // The final component is always re-checked against the filesystem: it is the + // one whose absence is being recorded, and a cached answer would be a stale + // one. Only the prefix directories are reused. + const cached = isFinal ? undefined : cache.get(prefix); + if (cached) { + handle = cached; + continue; + } + const child = anchoredChild(handle, component); + let childStat; + try { + childStat = lstatChild(child); + } catch (error) { + if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') throw error; + const parentStat = fs.fstatSync(handle.fd, { bigint: true }); + if (!parentStat.isDirectory()) { + throw new Error(`Absence parent is no longer a directory for ${repoPath}`); + } + mutationGuards.push({ + type: 'absence', + // The handle is the holder the guard verifies against, and `ref` is the + // child path already built through the anchoredChild chokepoint — the + // guard must never re-derive that name itself. + handle, + ref: child, + fd: handle.fd, + repoPath, + parentMutationIdentity: statIdentity(parentStat), + }); + return; + } + if (isFinal) { + throw new Error(`${repoPath} appeared while its absence was being anchored`); + } + if (childStat.isSymbolicLink() || !childStat.isDirectory()) { + throw new Error(`Refusing a non-directory parent while anchoring absence for ${repoPath}`); + } + requireAbsenceAnchorCapacity(cache); + const childFd = openVerifiedDirectory(child.path, ANCHORED_DIRECTORY_FLAGS); + const expectedPath = path.join(handle.expectedPath, component); + let next; + try { + if (!anchoringBackend().descriptorMatchesChild(childFd, expectedPath, childStat)) { + throw new Error( + `Absence parent descriptor does not match its verified inode for ${repoPath}`, + ); + } + next = { + fd: childFd, + expectedPath, + chain: [...handle.chain, { expectedPath, identity: stableDirectoryIdentity(childStat) }], + descriptors: [...handle.descriptors, childFd], + }; + } catch (error) { + fs.closeSync(childFd); + throw error; + } + cache.set(prefix, next); + handle = next; + } + throw new Error(`Could not anchor absence for ${repoPath}`); +} + +function materializeRecord(repo, statusRecord, layers, mutationGuards, testHooks, walkState) { const head = layers.head(statusRecord.path); const index = layers.index(statusRecord.path); const expectedKind = index.kind === 'gitlink' || head.kind === 'gitlink' ? 'gitlink' : null; - guardPathParents(repo, statusRecord.path, mutationGuards); + guardPathParents(repo, statusRecord.path, mutationGuards, walkState.guardedDirectories); const filesystem = filesystemObject( path.join(repo, ...statusRecord.path.split('/')), expectedKind, mutationGuards, testHooks, ); - if (filesystem.kind === ABSENT) recordAnchoredAbsence(repo, statusRecord.path, mutationGuards); + if (filesystem.kind === ABSENT) { + recordAnchoredAbsence(repo, statusRecord.path, mutationGuards, walkState.absenceCache); + } if (statusRecord.directory_hint && filesystem.kind !== 'directory') { throw new Error( `Git reported an embedded directory but found ${filesystem.kind}: ${statusRecord.path}`, @@ -789,9 +875,15 @@ export function serializeDirtyRecords(entries) { } function assertRepository(repoInput) { - const repo = fs.realpathSync(requireString(repoInput, 'repo')); + // realpathSync.native, not realpathSync: the JS resolver preserves a Windows + // 8.3 short component (C:\Users\RUNNER~1\...) while git always reports the long + // form, so the two would never compare equal and every caller would be told the + // worktree root is not the worktree root it just named. + const repo = fs.realpathSync.native(requireString(repoInput, 'repo')); const topLevelResult = git(repo, ['rev-parse', '--show-toplevel']); - const topLevel = fs.realpathSync(decodeUtf8(topLevelResult.stdout, 'repository root').trim()); + const topLevel = fs.realpathSync.native( + decodeUtf8(topLevelResult.stdout, 'repository root').trim(), + ); if (topLevel !== repo) throw new Error(`--repo must be the Git worktree root (${topLevel})`); return repo; } @@ -882,17 +974,48 @@ function stableFileIdentity(stat) { return [stat.dev, stat.ino, stat.mode, stat.size].map(String).join(':'); } +// The two backends below differ in one decisive way, and it is worth stating +// plainly because the security properties are not the same. +// +// Linux ANCHORS. A name is resolved through /proc/self/fd//, which +// starts the walk at the inode the descriptor holds, so a parent that is renamed +// away cannot be traversed at all: the descriptor keeps pointing at the original +// directory and the impostor planted at the same name is simply never reached. +// +// macOS VERIFIES. Node cannot resolve a name relative to a descriptor there — +// /dev/fd/ is not a magic link (it stats as the directory but every attempt +// to traverse a child through it returns ENOENT), and fcntl F_GETPATH is a +// name-cache snapshot rather than a live anchor. So the Darwin backend resolves +// lexically, holds an open descriptor on every element of the chain, and proves +// before and after each operation that the path chain still names exactly the +// inodes it is holding. That DETECTS a swapped parent and aborts the write; it +// does not make the swap impossible the way the Linux path does. A swap landing +// inside the window between a check and the call it guards is caught by the +// following check, after the fact, rather than being unreachable. +// +// Every other platform gets neither and is refused outright. function requireDescriptorAnchoring() { - if ( - process.platform !== 'linux' || - fs.constants.O_DIRECTORY === undefined || - fs.constants.O_NOFOLLOW === undefined || - !fs.existsSync('/proc/self/fd') - ) { - throw new Error( - 'Safe generated-plan writes require Linux /proc/self/fd and O_DIRECTORY/O_NOFOLLOW; refusing an unanchored write', - ); + const directoryFlagsAvailable = + fs.constants.O_DIRECTORY !== undefined && fs.constants.O_NOFOLLOW !== undefined; + if (process.platform === 'linux') { + if (!directoryFlagsAvailable || !fs.existsSync('/proc/self/fd')) { + throw new Error( + 'Safe generated-plan writes require Linux /proc/self/fd and O_DIRECTORY/O_NOFOLLOW; refusing an unanchored write', + ); + } + return; } + if (process.platform === 'darwin') { + if (!directoryFlagsAvailable) { + throw new Error( + 'Safe generated-plan writes require macOS O_DIRECTORY/O_NOFOLLOW; refusing an unverified write', + ); + } + return; + } + throw new Error( + `Safe generated-plan writes require Linux /proc/self/fd or macOS O_DIRECTORY/O_NOFOLLOW; ${process.platform} offers neither, so refusing an unanchored write`, + ); } function descriptorPath(fd, childName) { @@ -900,157 +1023,352 @@ function descriptorPath(fd, childName) { return childName === undefined ? base : path.join(base, childName); } -function externalDescriptorPath(fd, childName) { - const base = `/proc/${process.pid}/fd/${fd}`; - return childName === undefined ? base : path.join(base, childName); +// Directory opens are plain O_RDONLY|O_DIRECTORY|O_NOFOLLOW|O_CLOEXEC on both +// platforms, and deliberately nothing else. +// +// O_NOFOLLOW_ANY (macOS 11+) used to be ORed in here on the theory that XNU +// ignores unrecognized open flag bits, so it would be inert where unsupported. +// That was wrong: combined with O_DIRECTORY macOS rejects it outright with +// EINVAL, and every directory open on Darwin failed. It is gone and is not +// coming back behind a probe or a degrade-on-EINVAL path — the per-component +// O_NOFOLLOW walk is what delivers the guarantee. Rust's cap-std, the closest +// reference implementation of this problem, has not adopted O_NOFOLLOW_ANY +// either (their issue #179 is still open). +function openVerifiedDirectory(absolute, flags) { + return fs.openSync(absolute, flags); } -const RENAME_NOREPLACE_SCRIPT = String.raw` -import ctypes -import errno -import os -import sys - -libc = ctypes.CDLL(None, use_errno=True) -try: - renameat2 = libc.renameat2 -except AttributeError: - print("libc does not expose renameat2", file=sys.stderr) - raise SystemExit(125) - -renameat2.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint] -renameat2.restype = ctypes.c_int -result = renameat2(-100, os.fsencode(sys.argv[1]), -100, os.fsencode(sys.argv[2]), 1) -if result != 0: - error_number = ctypes.get_errno() - error_name = errno.errorcode.get(error_number, "UNKNOWN") - print(f"renameat2 RENAME_NOREPLACE failed: {error_name}: {os.strerror(error_number)}", file=sys.stderr) - raise SystemExit(17 if error_number == errno.EEXIST else 126) -`; - -let atomicMoverPath; - -function spawnHeldExecutable(executable, args, options) { - const before = fs.fstatSync(executable.fd, { bigint: true }); - if (!before.isFile() || statIdentity(before) !== executable.identity) { - throw new Error('Validated Python executable changed before invocation'); - } - const result = spawnSync('/proc/self/fd/3', args, { - ...options, - stdio: ['ignore', 'pipe', 'pipe', executable.fd], - }); - const after = fs.fstatSync(executable.fd, { bigint: true }); - assertStableIdentity(before, after, 'validated Python executable'); - return result; +// File opens additionally get O_NONBLOCK, which directory opens do not need: +// it stops a FIFO swapped in at the target name from wedging the process on +// open. The identity comparison that follows rejects the FIFO anyway, but only +// if we ever get as far as running it. +function openVerifiedFile(absolute, flags, mode) { + const nonBlocking = flags | (fs.constants.O_NONBLOCK ?? 0); + return mode === undefined + ? fs.openSync(absolute, nonBlocking) + : fs.openSync(absolute, nonBlocking, mode); } -function validatedPathExecutable(candidate) { - if (!path.isAbsolute(candidate)) return null; - const candidateDirectory = path.dirname(candidate); - let resolvedDirectory; - let resolved; - let directoryStats; - let executableStat; +// The publish primitive, identical on both platforms. +// +// link() is the portable no-replace publish: it fails with EEXIST if the +// destination name is taken — by a regular file, by a directory, or by a symlink, +// live or dangling — and it never follows that symlink to clobber its target. +// It also works where renameat2(RENAME_NOREPLACE) does not, notably v9fs, which +// is why the WSL2 9p case that used to fail every time now works. +// +// The published file is the same inode as the temporary, so every identity +// comparison the callers already make still holds, and validateCommittedPlan +// becomes strictly stronger: it compares the destination against the exact inode +// whose bytes were fsynced. +// +// On Linux both paths are /proc/self/fd//, so the publish is anchored +// to the held parent descriptors exactly like every other operation. +// link(2) BUGS: "On NFS filesystems, the return code may be wrong in case the NFS +// server performs the link creation and dies before it can say so. Use stat(2) to +// find out if the link got created." open(2) NOTES gives the remedy this +// implements: on a reported failure, stat the source and see whether its link +// count reached 2. A false positive would need someone to have hardlinked a +// 16-random-byte name inside a directory we hold open — and validateCommittedPlan +// still proves the destination is the exact temporary inode afterwards. +function linkCreatedDespiteError(sourcePath) { try { - resolvedDirectory = fs.realpathSync(candidateDirectory); - resolved = fs.realpathSync(candidate); - const resolvedExecutableDirectory = fs.realpathSync(path.dirname(resolved)); - directoryStats = [...new Set([resolvedDirectory, resolvedExecutableDirectory])].map( - (directory) => fs.statSync(directory), - ); - executableStat = fs.lstatSync(resolved); - fs.accessSync(resolved, fs.constants.X_OK); + return fs.statSync(sourcePath, { bigint: true }).nlink === 2n; } catch { - return null; + return false; } - if ( - directoryStats.some((stat) => !stat.isDirectory()) || - !executableStat.isFile() || - executableStat.isSymbolicLink() - ) { - return null; - } - const uid = typeof process.getuid === 'function' ? process.getuid() : null; - const trustedOwner = (stat) => uid === null || stat.uid === 0 || stat.uid === uid; - if ( - directoryStats.some((stat) => !trustedOwner(stat) || (stat.mode & 0o022) !== 0) || - !trustedOwner(executableStat) || - (executableStat.mode & 0o022) !== 0 - ) { - return null; - } - return resolved; } -function resolveAtomicMover() { - if (atomicMoverPath) return atomicMoverPath; - const candidates = new Set(); - for (const entry of (process.env.PATH ?? '').split(path.delimiter)) { - if (entry && path.isAbsolute(entry)) candidates.add(path.join(entry, 'python3')); - } - for (const entry of ['/usr/local/bin/python3', '/usr/bin/python3', '/bin/python3']) { - candidates.add(entry); - } - for (const candidate of candidates) { - const resolved = validatedPathExecutable(candidate); - if (!resolved) continue; - let fd; - try { - fd = fs.openSync( - resolved, - fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_CLOEXEC ?? 0), - ); - } catch { - continue; +function linkNoReplace(sourcePath, destinationPath) { + try { + fs.linkSync(sourcePath, destinationPath); + } catch (error) { + // Callers treat "destination taken" as a distinct outcome, not a failure. + if (error?.code === 'EEXIST') return false; + if (!linkCreatedDespiteError(sourcePath)) { + // FAT, Coda, and some SMB/FUSE/virtiofs mounts have no hardlinks at all. + // Git falls back to rename here, but git can afford to lose collision + // detection because its objects are content-addressed; a plan destination + // is a plain name, so a replacing rename would silently clobber whatever + // is already there. Refuse loudly instead. + if (error?.code === 'EPERM' || error?.code === 'ENOTSUP' || error?.code === 'EMLINK') { + throw new Error( + `Generated-plan publication requires hard links, which this filesystem refused (${error.code}); refusing to fall back to a replacing rename`, + ); + } + throw error; } - const opened = fs.fstatSync(fd, { bigint: true }); - const executable = { fd, identity: statIdentity(opened), resolved }; - const version = spawnHeldExecutable( - executable, - ['-I', '-S', '-c', 'import sys; print(sys.version_info[0])'], - { - encoding: 'utf8', - env: { ...process.env, LANG: 'C', LC_ALL: 'C' }, - timeout: 10_000, - windowsHide: true, - }, - ); - if (version.status === 0 && version.stdout.trim() === '3') { - atomicMoverPath = executable; - return executable; - } - fs.closeSync(fd); } - throw new Error( - 'Safe generated-plan publication requires a trusted absolute Python 3 PATH candidate with libc renameat2 support', - ); -} - -function atomicMoveNoReplace(source, destination) { - const mover = resolveAtomicMover(); - const result = spawnHeldExecutable( - mover, - ['-I', '-S', '-c', RENAME_NOREPLACE_SCRIPT, source, destination], - { - encoding: 'utf8', - env: { ...process.env, LANG: 'C', LC_ALL: 'C' }, - timeout: 10_000, - windowsHide: true, - }, - ); - if (result.error) throw result.error; - if (result.status === 17) return false; - if (result.status !== 0) { - throw new Error( - `Atomic no-replace move failed (${result.status}): ${(result.stderr ?? '').trim()}`, - ); + try { + fs.unlinkSync(sourcePath); + } catch { + // The link succeeded, so the plan IS published. A temporary name left behind + // is a stray file, not an unpublished plan: reporting it as a failure would + // be a lie, and rolling back would unpublish a plan that is already live. } return true; } -function lstatOptional(absolute) { +// A directory holder is anything that owns a verified chain: a plan-parent +// handle, a ref's parent directory, or an absence guard. Two arrays describe it, +// both root-first and the same length — `chain` records each element's expected +// path and dev/ino/mode, and `descriptors` holds an open descriptor on each. +// +// Holding those descriptors is load-bearing rather than decorative. dev/ino/mode +// is unique only among *live* inodes: an inode number freed by an rmdir is handed +// straight back to the next mkdir, so a replacement directory can reproduce a +// recorded identity exactly. An open descriptor pins the inode, so the number +// cannot be recycled for as long as the holder exists. +function verifyPinnedDescriptors(holder) { + const { chain, descriptors } = holder; + if (!Array.isArray(descriptors) || descriptors.length !== chain.length) { + throw new Error('Generated-plan parent chain is missing the descriptors that pin it'); + } + chain.forEach((item, index) => { + const pinned = fs.fstatSync(descriptors[index], { bigint: true }); + if (!pinned.isDirectory() || stableDirectoryIdentity(pinned) !== item.identity) { + throw new Error('Generated-plan parent descriptor changed during the write'); + } + }); +} + +function verifyLexicalChain(holder) { + for (const item of holder.chain) { + let lexical; + try { + lexical = fs.lstatSync(item.expectedPath, { bigint: true }); + } catch (error) { + if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') throw error; + // A parent renamed out from under us is a mismatch, not a missing file: + // reporting the raw ENOENT would leak an unrelated-looking error out of a + // check whose whole job is to say the chain no longer holds. + throw new Error('Generated-plan lexical parent no longer matches its directory descriptor'); + } + if ( + lexical.isSymbolicLink() || + !lexical.isDirectory() || + stableDirectoryIdentity(lexical) !== item.identity + ) { + throw new Error('Generated-plan lexical parent no longer matches its directory descriptor'); + } + } +} + +// The whole platform seam, in five methods. Everything else an operation does is +// identical on both platforms and lives in the shared functions below. +// +// Only two things actually differ: how a name becomes a path, and what guard +// wraps the operation that uses it. +// +// Linux ANCHORS. /proc/self/fd// starts the walk at the inode the +// descriptor holds, so a parent renamed away cannot be traversed at all and the +// guard is a no-op — there is nothing left to verify. +// +// macOS VERIFIES. It resolves lexically, so before and after every operation it +// proves that each element of the path chain still names the exact inode being +// held for it. That DETECTS a swapped parent and aborts; it does not make the +// swap impossible. A swap landing inside the window is caught by the trailing +// check, after the fact, rather than being unreachable. The check runs after a +// failure too, because a verdict observed through a chain that has since changed +// is not a verdict. +const LINUX_ANCHORING = { + childPath(dirHandle, childName) { + return descriptorPath(dirHandle.fd, childName); + }, + verified(holders, run) { + return run(); + }, + descriptorMatchesChild(fd, expectedPath) { + return fs.realpathSync.native(descriptorPath(fd)) === expectedPath; + }, + parentStillResolves(parentHandle) { + return fs.realpathSync.native(descriptorPath(parentHandle.fd)) === parentHandle.expectedPath; + }, + verifyAbsentChild(guard) { + if (absentChildIsPresent(guard.ref)) { + throw new Error(`${guard.repoPath} appeared before evidence materialization completed`); + } + }, +}; + +const DARWIN_ANCHORING = { + childPath(dirHandle, childName) { + return path.join(dirHandle.expectedPath, childName); + }, + verified(holders, run) { + const list = Array.isArray(holders) ? holders : [holders]; + const proveChain = () => { + for (const holder of list) { + verifyPinnedDescriptors(holder); + verifyLexicalChain(holder); + } + }; + proveChain(); + let value; + try { + value = run(); + } catch (error) { + proveChain(); + throw error; + } + proveChain(); + return value; + }, + descriptorMatchesChild(fd, _expectedPath, childStat) { + // There is no live fd-to-path oracle on macOS (F_GETPATH is a name-cache + // snapshot, not an anchor), so escape is decided the other way round: the + // name was just resolved under a verified chain, and the descriptor opened + // from it counts only if it is that same inode. + const opened = fs.fstatSync(fd, { bigint: true }); + return ( + opened.isDirectory() && stableDirectoryIdentity(opened) === stableDirectoryIdentity(childStat) + ); + }, + parentStillResolves(parentHandle) { + // Both halves are needed: a directory renamed away keeps its inode, so the + // descriptors alone still match and only the lexical half notices it moved. + try { + verifyPinnedDescriptors(parentHandle); + verifyLexicalChain(parentHandle); + } catch { + return false; + } + return true; + }, + verifyAbsentChild(guard) { + let present; + try { + present = DARWIN_ANCHORING.verified(guard.handle, () => absentChildIsPresent(guard.ref)); + } catch (error) { + // A chain that no longer holds makes the absence verdict meaningless, and + // the caller reports that as the anchor changing rather than as a stray + // parent-descriptor error. Linux cannot reach this: its guard is a no-op. + throw new Error( + `Absence anchor changed for ${guard.repoPath}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + if (present) { + throw new Error(`${guard.repoPath} appeared before evidence materialization completed`); + } + }, +}; + +const ANCHORING_BACKENDS = new Map([ + ['linux', LINUX_ANCHORING], + ['darwin', DARWIN_ANCHORING], +]); + +function anchoringBackend() { + const backend = ANCHORING_BACKENDS.get(process.platform); + if (!backend) { + // requireDescriptorAnchoring normally refuses first; this is the same answer + // from the other side, so an unsupported platform can never fall through to + // whichever backend happened to be the ternary's default. + throw new Error( + `No generated-plan anchoring backend for ${process.platform}; refusing an unanchored write`, + ); + } + return backend; +} + +// Open, fstat, compare, close on mismatch. The descriptor never escapes this +// function unless it refers to the inode the caller already verified by name, so +// a lexical open that landed anywhere else cannot be used by accident. On Linux +// the comparison passes trivially — the /proc walk already resolved from the +// held parent — and costs one fstat to keep the guarantee structural rather than +// dependent on which backend is in play. +function adoptVerifiedFile(ref, expectedStat, flags) { + const fd = openVerifiedFile(ref.path, flags); + let opened; try { - return fs.lstatSync(absolute, { bigint: true }); + opened = fs.fstatSync(fd, { bigint: true }); + } catch (error) { + fs.closeSync(fd); + throw error; + } + if (stableFileIdentity(opened) !== stableFileIdentity(expectedStat)) { + fs.closeSync(fd); + return null; + } + return fd; +} + +function absentChildIsPresent(ref) { + try { + fs.lstatSync(ref.path, { bigint: true }); + } catch (error) { + if (error?.code === 'ENOENT') return false; + throw error; + } + return true; +} + +// The operations. Each is the same on both platforms; only the guard differs. +function lstatChild(ref) { + return anchoringBackend().verified(ref.dir, () => fs.lstatSync(ref.path, { bigint: true })); +} + +function openChildRead(ref, flags, expectedStat) { + return anchoringBackend().verified(ref.dir, () => { + const fd = adoptVerifiedFile(ref, expectedStat, flags); + if (fd === null) { + throw new Error(`${ref.name} was replaced between its verified stat and its no-follow open`); + } + return fd; + }); +} + +function createChild(ref, flags, mode) { + // O_CREAT|O_EXCL|O_NOFOLLOW is atomic at the leaf, so the only thing the guard + // has to cover is which directory the leaf landed in. + return anchoringBackend().verified(ref.dir, () => openVerifiedFile(ref.path, flags, mode)); +} + +function mkdirChild(ref, mode) { + anchoringBackend().verified(ref.dir, () => fs.mkdirSync(ref.path, { mode })); +} + +function publishNoReplace(sourceRef, destinationRef) { + return anchoringBackend().verified([sourceRef.dir, destinationRef.dir], () => + linkNoReplace(sourceRef.path, destinationRef.path), + ); +} + +// The single place a name becomes a path, and therefore the right place to +// enforce that a name is one ordinary component. +// +// A trailing separator is the sharp edge here, not a tidiness concern: +// open(path, O_NOFOLLOW) FOLLOWS a symlink when path ends in "/" — the trap +// behind CVE-2026-39822 / golang/go#79005, which let os.Root escape its own +// root. path.join preserves that trailing slash, so a component carrying one +// would turn every no-follow open in this file into a following one. +// normalizeRepoPath already rejects such components upstream; this is the +// chokepoint that makes it true for every caller, including the generated +// temporary and vault names that never pass through it. +function anchoredChild(dirHandle, childName) { + if ( + typeof childName !== 'string' || + childName === '' || + childName === '.' || + childName === '..' || + childName.includes('/') || + childName.includes('\\') || + childName.includes('\0') + ) { + throw new Error(`Refusing to resolve ${JSON.stringify(childName)} as a single path component`); + } + return { + dir: dirHandle, + name: childName, + path: anchoringBackend().childPath(dirHandle, childName), + }; +} + +function lstatAnchoredOptional(ref) { + try { + return lstatChild(ref); } catch (error) { if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return null; throw error; @@ -1063,39 +1381,37 @@ function openPlanParent( { createMissing = true, purpose = 'Generated-plan' } = {}, ) { requireDescriptorAnchoring(); - const flags = - fs.constants.O_RDONLY | - fs.constants.O_DIRECTORY | - fs.constants.O_NOFOLLOW | - (fs.constants.O_CLOEXEC ?? 0); + // Root-first and index-aligned with `chain`: verifyPinnedDescriptors relies on + // that, and the descriptors are what pin each recorded inode against reuse. const descriptors = []; try { - let currentFd = fs.openSync(repo, flags); + let currentFd = openVerifiedDirectory(repo, ANCHORED_DIRECTORY_FLAGS); descriptors.push(currentFd); const rootStat = fs.fstatSync(currentFd, { bigint: true }); const chain = [{ expectedPath: repo, identity: stableDirectoryIdentity(rootStat) }]; + let currentHandle = { fd: currentFd, expectedPath: repo, chain, descriptors }; const traversed = []; for (const component of parentComponents) { traversed.push(component); - const anchoredChild = descriptorPath(currentFd, component); + const child = anchoredChild(currentHandle, component); let childStat; let created = false; try { - childStat = fs.lstatSync(anchoredChild, { bigint: true }); + childStat = lstatChild(child); } catch (error) { if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') throw error; if (!createMissing) { throw new Error(`${purpose} parent does not exist: ${traversed.join('/')}`); } - fs.mkdirSync(anchoredChild, { mode: 0o755 }); - childStat = fs.lstatSync(anchoredChild, { bigint: true }); + mkdirChild(child, 0o755); + childStat = lstatChild(child); created = true; } if (childStat.isSymbolicLink() || !childStat.isDirectory()) { throw new Error(`${purpose} parent is not a real directory: ${traversed.join('/')}`); } const parentFd = currentFd; - const childFd = fs.openSync(anchoredChild, flags); + const childFd = openVerifiedDirectory(child.path, ANCHORED_DIRECTORY_FLAGS); descriptors.push(childFd); currentFd = childFd; if (created) { @@ -1103,18 +1419,16 @@ function openPlanParent( fs.fsyncSync(parentFd); } const expected = path.join(repo, ...traversed); - const actual = fs.realpathSync(descriptorPath(currentFd)); - if (actual !== expected) { + if (!anchoringBackend().descriptorMatchesChild(currentFd, expected, childStat)) { throw new Error(`${purpose} parent escaped the repository: ${traversed.join('/')}`); } const openedStat = fs.fstatSync(currentFd, { bigint: true }); chain.push({ expectedPath: expected, identity: stableDirectoryIdentity(openedStat) }); + currentHandle = { fd: currentFd, expectedPath: expected, chain, descriptors }; } - const stat = fs.fstatSync(currentFd, { bigint: true }); return { descriptors, fd: currentFd, - identity: stableDirectoryIdentity(stat), expectedPath: path.join(repo, ...parentComponents), chain, }; @@ -1134,9 +1448,16 @@ function closeDescriptors(descriptors) { } } +// A handle's identity IS its chain leaf's identity. Storing it twice meant two +// fstats a line apart and a re-stamp helper to keep them agreeing; deriving it +// removes both. +function handleIdentity(handle) { + return handle.chain[handle.chain.length - 1].identity; +} + function resolveGitDirectory(repo) { const result = git(repo, ['rev-parse', '--absolute-git-dir']); - return fs.realpathSync(decodeUtf8(result.stdout, 'Git administrative directory').trim()); + return fs.realpathSync.native(decodeUtf8(result.stdout, 'Git administrative directory').trim()); } function openBackupVault(repo, { createMissing = true } = {}) { @@ -1147,9 +1468,12 @@ function openBackupVault(repo, { createMissing = true } = {}) { }); fs.fchmodSync(handle.fd, 0o700); fs.fsyncSync(handle.fd); - const stat = fs.fstatSync(handle.fd, { bigint: true }); - handle.identity = stableDirectoryIdentity(stat); - handle.chain[handle.chain.length - 1].identity = handle.identity; + // mode is part of every directory identity, so hardening the vault changes the + // identity the chain recorded for it; without this the next verification would + // reject the directory it just hardened. + handle.chain[handle.chain.length - 1].identity = stableDirectoryIdentity( + fs.fstatSync(handle.fd, { bigint: true }), + ); return { ...handle, gitDirectory }; } @@ -1157,33 +1481,28 @@ function validatePlanParent(parentHandle) { const descriptorStat = fs.fstatSync(parentHandle.fd, { bigint: true }); if ( !descriptorStat.isDirectory() || - stableDirectoryIdentity(descriptorStat) !== parentHandle.identity + stableDirectoryIdentity(descriptorStat) !== handleIdentity(parentHandle) ) { throw new Error('Generated-plan parent descriptor changed during the write'); } - const descriptorRealPath = fs.realpathSync(descriptorPath(parentHandle.fd)); - if (descriptorRealPath !== parentHandle.expectedPath) { + if (!anchoringBackend().parentStillResolves(parentHandle)) { throw new Error('Generated-plan parent moved or was replaced during the write'); } - for (const item of parentHandle.chain) { - const lexicalStat = fs.lstatSync(item.expectedPath, { bigint: true }); - if ( - lexicalStat.isSymbolicLink() || - !lexicalStat.isDirectory() || - stableDirectoryIdentity(lexicalStat) !== item.identity - ) { - throw new Error('Generated-plan lexical parent no longer matches its directory descriptor'); - } - } + // Both halves come from the shared helpers rather than being restated here: an + // earlier hand-copy of the lexical loop lost verifyLexicalChain's ENOENT/ENOTDIR + // translation, so a renamed parent could surface a raw errno from a function + // with a dozen call sites. + verifyPinnedDescriptors(parentHandle); + verifyLexicalChain(parentHandle); } function inspectPlanDestination( - finalPath, + finalRef, { replace, expectedIdentity, mustBeAbsent = false } = {}, ) { let stat; try { - stat = fs.lstatSync(finalPath, { bigint: true }); + stat = lstatChild(finalRef); } catch (error) { if (error?.code === 'ENOENT') { if (expectedIdentity) throw new Error('Generated plan disappeared during the write'); @@ -1201,19 +1520,17 @@ function inspectPlanDestination( if (expectedIdentity && identity !== expectedIdentity) { throw new Error('Generated plan changed during the write'); } - return identity; + return stat; } -function openExistingPlanDestination(finalPath, replace) { - const identity = inspectPlanDestination(finalPath, { replace }); - if (identity === null) { +function openExistingPlanDestination(finalRef, replace) { + const stat = inspectPlanDestination(finalRef, { replace }); + if (stat === null) { if (replace) throw new Error('Deepen mode requires an existing generated plan to replace'); return { fd: undefined, identity: null, stableIdentity: null }; } - const fd = fs.openSync( - finalPath, - fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_CLOEXEC ?? 0), - ); + const identity = statIdentity(stat); + const fd = openChildRead(finalRef, VERIFIED_READ_FLAGS, stat); try { const opened = fs.fstatSync(fd, { bigint: true }); if (!opened.isFile() || statIdentity(opened) !== identity) { @@ -1264,8 +1581,8 @@ function hashOpenFile(fd, label) { }; } -function validateCommittedPlan(finalPath, tempFd, expectedTemp, testHooks) { - const before = fs.lstatSync(finalPath, { bigint: true }); +function validateCommittedPlan(finalRef, tempFd, expectedTemp, testHooks) { + const before = lstatChild(finalRef); if ( before.isSymbolicLink() || !before.isFile() || @@ -1273,19 +1590,16 @@ function validateCommittedPlan(finalPath, tempFd, expectedTemp, testHooks) { ) { throw new Error('Generated-plan destination failed its first post-write identity check'); } - const finalFd = fs.openSync( - finalPath, - fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_CLOEXEC ?? 0), - ); + const finalFd = openChildRead(finalRef, VERIFIED_READ_FLAGS, before); try { const opened = fs.fstatSync(finalFd, { bigint: true }); if (!opened.isFile() || stableFileIdentity(opened) !== expectedTemp.identity) { throw new Error('Generated-plan destination changed while its no-follow descriptor opened'); } - testHooks?.afterFinalOpen?.({ fd: finalFd, finalPath }); + testHooks?.afterFinalOpen?.({ fd: finalFd, finalPath: finalRef.path }); const committedViaTemp = hashOpenFile(tempFd, 'generated-plan committed file'); const committedViaPath = hashOpenFile(finalFd, 'generated-plan destination descriptor'); - const after = fs.lstatSync(finalPath, { bigint: true }); + const after = lstatChild(finalRef); const openedAfter = fs.fstatSync(finalFd, { bigint: true }); if ( after.isSymbolicLink() || @@ -1320,22 +1634,19 @@ function copyOpenFile(sourceFd, destinationFd, label) { return after; } -function openVerifiedPathFile(absolute, label) { - const before = fs.lstatSync(absolute, { bigint: true }); +function openVerifiedAnchoredFile(ref, label, knownStat) { + const before = knownStat ?? lstatChild(ref); if (before.isSymbolicLink() || !before.isFile()) { throw new Error(`${label} is not a regular no-follow file`); } - const fd = fs.openSync( - absolute, - fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_CLOEXEC ?? 0), - ); + const fd = openChildRead(ref, VERIFIED_READ_FLAGS, before); try { const opened = fs.fstatSync(fd, { bigint: true }); if (!opened.isFile() || stableFileIdentity(opened) !== stableFileIdentity(before)) { throw new Error(`${label} changed while its descriptor opened`); } const layer = hashOpenFile(fd, label); - const after = fs.lstatSync(absolute, { bigint: true }); + const after = lstatChild(ref); if (after.isSymbolicLink() || !after.isFile() || stableFileIdentity(after) !== layer.identity) { throw new Error(`${label} changed after verification`); } @@ -1358,10 +1669,10 @@ export function readPlanSafely({ repo: repoInput, generatedPlanPath, testHooks } let fd; try { validatePlanParent(parentHandle); - const finalPath = descriptorPath(parentHandle.fd, finalName); + const finalRef = anchoredChild(parentHandle, finalName); let before; try { - before = fs.lstatSync(finalPath, { bigint: true }); + before = lstatChild(finalRef); } catch (error) { if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') { throw new Error(`Loaded plan does not exist: ${generatedPlan}`); @@ -1371,15 +1682,12 @@ export function readPlanSafely({ repo: repoInput, generatedPlanPath, testHooks } if (before.isSymbolicLink() || !before.isFile()) { throw new Error('Loaded plan must be a regular file, never a symlink'); } - fd = fs.openSync( - finalPath, - fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_CLOEXEC ?? 0), - ); + fd = openChildRead(finalRef, VERIFIED_READ_FLAGS, before); const opened = fs.fstatSync(fd, { bigint: true }); if (!opened.isFile() || statIdentity(opened) !== statIdentity(before)) { throw new Error('Loaded plan changed while its no-follow descriptor opened'); } - testHooks?.afterPlanOpen?.({ fd, finalPath }); + testHooks?.afterPlanOpen?.({ fd, finalPath: finalRef.path }); const chunks = []; let total = 0; const buffer = Buffer.allocUnsafe(64 * 1024); @@ -1394,7 +1702,7 @@ export function readPlanSafely({ repo: repoInput, generatedPlanPath, testHooks } decodeUtf8(contents, 'loaded plan'); const after = fs.fstatSync(fd, { bigint: true }); assertStableIdentity(opened, after, 'loaded plan'); - const pathAfter = fs.lstatSync(finalPath, { bigint: true }); + const pathAfter = lstatChild(finalRef); if ( pathAfter.isSymbolicLink() || !pathAfter.isFile() || @@ -1419,24 +1727,22 @@ function artifactGitPath(name) { return `gitnexus-plan-backups/${name}`; } -function verifyVaultArtifactFromFreshRoot(repo, gitPath, expectedLayer) { - const components = gitPath.split('/'); - if (components.length !== 2 || components[0] !== 'gitnexus-plan-backups') { - throw new Error(`Invalid Git-admin artifact path: ${gitPath}`); - } +function verifyVaultArtifactFromFreshRoot(repo, name, expectedLayer) { const freshVault = openBackupVault(repo, { createMissing: false }); try { validatePlanParent(freshVault); - const opened = openVerifiedPathFile( - descriptorPath(freshVault.fd, components[1]), - `Git-admin artifact ${gitPath}`, + const opened = openVerifiedAnchoredFile( + anchoredChild(freshVault, name), + `Git-admin artifact ${artifactGitPath(name)}`, ); try { if ( opened.layer.identity !== expectedLayer.identity || opened.layer.digest !== expectedLayer.digest ) { - throw new Error(`Git-admin artifact changed before fresh-root verification: ${gitPath}`); + throw new Error( + `Git-admin artifact changed before fresh-root verification: ${artifactGitPath(name)}`, + ); } } finally { fs.closeSync(opened.fd); @@ -1449,16 +1755,8 @@ function verifyVaultArtifactFromFreshRoot(repo, gitPath, expectedLayer) { function createVaultCopyFromFd(repo, vault, sourceFd, role) { validatePlanParent(vault); const name = `.gitnexus-plan-${role}-${process.pid}-${randomBytes(16).toString('hex')}.bak`; - const absolute = descriptorPath(vault.fd, name); - const destinationFd = fs.openSync( - absolute, - fs.constants.O_RDWR | - fs.constants.O_CREAT | - fs.constants.O_EXCL | - fs.constants.O_NOFOLLOW | - (fs.constants.O_CLOEXEC ?? 0), - 0o600, - ); + const artifact = anchoredChild(vault, name); + const destinationFd = createChild(artifact, VERIFIED_CREATE_FLAGS, 0o600); let destination; try { const sourceStat = copyOpenFile(sourceFd, destinationFd, role); @@ -1469,7 +1767,7 @@ function createVaultCopyFromFd(repo, vault, sourceFd, role) { if (source.size !== destination.size || source.digest !== destination.digest) { throw new Error(`${role} vault copy does not match its held source descriptor`); } - const pathStat = fs.lstatSync(absolute, { bigint: true }); + const pathStat = lstatChild(artifact); if ( pathStat.isSymbolicLink() || !pathStat.isFile() || @@ -1481,24 +1779,15 @@ function createVaultCopyFromFd(repo, vault, sourceFd, role) { } finally { fs.closeSync(destinationFd); } - const gitPath = artifactGitPath(name); - verifyVaultArtifactFromFreshRoot(repo, gitPath, destination); - return { role, gitPath, layer: destination }; + verifyVaultArtifactFromFreshRoot(repo, name, destination); + return { role, gitPath: artifactGitPath(name), layer: destination }; } function createVaultCopyFromBytes(repo, vault, contents, role) { validatePlanParent(vault); const name = `.gitnexus-plan-${role}-${process.pid}-${randomBytes(16).toString('hex')}.bak`; - const absolute = descriptorPath(vault.fd, name); - const fd = fs.openSync( - absolute, - fs.constants.O_RDWR | - fs.constants.O_CREAT | - fs.constants.O_EXCL | - fs.constants.O_NOFOLLOW | - (fs.constants.O_CLOEXEC ?? 0), - 0o600, - ); + const artifact = anchoredChild(vault, name); + const fd = createChild(artifact, VERIFIED_CREATE_FLAGS, 0o600); let layer; try { writeAll(fd, contents); @@ -1508,7 +1797,7 @@ function createVaultCopyFromBytes(repo, vault, contents, role) { if (layer.size !== BigInt(contents.length) || layer.digest !== sha256(contents)) { throw new Error(`${role} vault copy does not match the intended plan bytes`); } - const pathStat = fs.lstatSync(absolute, { bigint: true }); + const pathStat = lstatChild(artifact); if ( pathStat.isSymbolicLink() || !pathStat.isFile() || @@ -1520,32 +1809,31 @@ function createVaultCopyFromBytes(repo, vault, contents, role) { } finally { fs.closeSync(fd); } - const gitPath = artifactGitPath(name); - verifyVaultArtifactFromFreshRoot(repo, gitPath, layer); - return { role, gitPath, layer }; + verifyVaultArtifactFromFreshRoot(repo, name, layer); + return { role, gitPath: artifactGitPath(name), layer }; } function movePathToVault(repo, sourceHandle, sourceName, vault, role) { - const source = descriptorPath(sourceHandle.fd, sourceName); - if (!lstatOptional(source)) return null; + const source = anchoredChild(sourceHandle, sourceName); + if (!lstatAnchoredOptional(source)) return null; const name = `.gitnexus-plan-${role}-${process.pid}-${randomBytes(16).toString('hex')}.bak`; - const destination = descriptorPath(vault.fd, name); - const moved = atomicMoveNoReplace( - externalDescriptorPath(sourceHandle.fd, sourceName), - externalDescriptorPath(vault.fd, name), - ); + const destination = anchoredChild(vault, name); + const moved = publishNoReplace(source, destination); if (!moved) throw new Error(`${role} preservation destination unexpectedly exists`); fs.fsyncSync(sourceHandle.fd); if (vault.fd !== sourceHandle.fd) fs.fsyncSync(vault.fd); - const sourceAfter = lstatOptional(source); - const destinationAfter = lstatOptional(destination); + const sourceAfter = lstatAnchoredOptional(source); + const destinationAfter = lstatAnchoredOptional(destination); if (sourceAfter || !destinationAfter) { throw new Error(`${role} could not be atomically moved into the Git-admin vault`); } - const opened = openVerifiedPathFile(destination, `${role} Git-admin artifact`); - const gitPath = artifactGitPath(name); - verifyVaultArtifactFromFreshRoot(repo, gitPath, opened.layer); - return { role, gitPath, layer: opened.layer, fd: opened.fd }; + const opened = openVerifiedAnchoredFile( + destination, + `${role} Git-admin artifact`, + destinationAfter, + ); + verifyVaultArtifactFromFreshRoot(repo, name, opened.layer); + return { role, gitPath: artifactGitPath(name), layer: opened.layer, fd: opened.fd }; } function formatPreservedArtifacts(artifacts) { @@ -1600,10 +1888,10 @@ export function writePlanSafely({ const finalName = components.pop(); let parentHandle; let vaultHandle; - let tempPath; + let tempRef; let tempName; let tempFd; - let finalPath; + let finalRef; let expectedTemp; let originalDestination; let priorBackup; @@ -1611,7 +1899,6 @@ export function writePlanSafely({ try { parentHandle = openPlanParent(repo, components); vaultHandle = openBackupVault(repo); - resolveAtomicMover(); const parentDevice = fs.fstatSync(parentHandle.fd, { bigint: true }).dev; const vaultDevice = fs.fstatSync(vaultHandle.fd, { bigint: true }).dev; if (parentDevice !== vaultDevice) { @@ -1622,19 +1909,11 @@ export function writePlanSafely({ testHooks?.afterParentOpen?.({ fd: parentHandle.fd, path: parentHandle.expectedPath }); validatePlanParent(parentHandle); validatePlanParent(vaultHandle); - finalPath = descriptorPath(parentHandle.fd, finalName); - originalDestination = openExistingPlanDestination(finalPath, shouldReplace); + finalRef = anchoredChild(parentHandle, finalName); + originalDestination = openExistingPlanDestination(finalRef, shouldReplace); tempName = `.gitnexus-plan-${process.pid}-${randomBytes(16).toString('hex')}.tmp`; - tempPath = descriptorPath(parentHandle.fd, tempName); - tempFd = fs.openSync( - tempPath, - fs.constants.O_RDWR | - fs.constants.O_CREAT | - fs.constants.O_EXCL | - fs.constants.O_NOFOLLOW | - (fs.constants.O_CLOEXEC ?? 0), - 0o600, - ); + tempRef = anchoredChild(parentHandle, tempName); + tempFd = createChild(tempRef, VERIFIED_CREATE_FLAGS, 0o600); writeAll(tempFd, contents); fs.fchmodSync(tempFd, 0o644); fs.fsyncSync(tempFd); @@ -1646,12 +1925,12 @@ export function writePlanSafely({ testHooks?.beforeRename?.({ fd: parentHandle.fd, path: parentHandle.expectedPath, - tempPath, + tempPath: tempRef.path, }); validatePlanParent(parentHandle); validatePlanParent(vaultHandle); validateOpenPlanDestination(originalDestination); - const tempPathStat = fs.lstatSync(tempPath, { bigint: true }); + const tempPathStat = lstatChild(tempRef); const currentTemp = hashOpenFile(tempFd, 'generated-plan temporary file'); if ( tempPathStat.isSymbolicLink() || @@ -1664,7 +1943,7 @@ export function writePlanSafely({ } if (shouldReplace) { - testHooks?.beforeBackupMove?.({ fd: parentHandle.fd, finalPath }); + testHooks?.beforeBackupMove?.({ fd: parentHandle.fd, finalPath: finalRef.path }); const originalLayer = hashOpenFile(originalDestination.fd, 'prior generated plan'); if (originalLayer.digest !== expectedDigest) { throw new Error( @@ -1673,7 +1952,7 @@ export function writePlanSafely({ } validatePlanParent(parentHandle); validateOpenPlanDestination(originalDestination); - inspectPlanDestination(finalPath, { + inspectPlanDestination(finalRef, { replace: true, expectedIdentity: originalDestination.identity, }); @@ -1691,20 +1970,20 @@ export function writePlanSafely({ ); throw new Error('Destination raced while the prior plan was moved into preservation'); } - if (lstatOptional(finalPath)) { + if (lstatAnchoredOptional(finalRef)) { throw new Error('Destination reappeared after the prior plan was preserved'); } } testHooks?.beforePublication?.({ fd: parentHandle.fd, - finalPath, - tempPath, + finalPath: finalRef.path, + tempPath: tempRef.path, replace: shouldReplace, }); validatePlanParent(parentHandle); validatePlanParent(vaultHandle); - const finalTempPathStat = fs.lstatSync(tempPath, { bigint: true }); + const finalTempPathStat = lstatChild(tempRef); const finalTemp = hashOpenFile(tempFd, 'generated-plan temporary file'); if ( finalTempPathStat.isSymbolicLink() || @@ -1715,19 +1994,25 @@ export function writePlanSafely({ ) { throw new Error('Generated-plan temporary path or content changed at publication'); } - atomicMoveNoReplace( - externalDescriptorPath(parentHandle.fd, tempName), - externalDescriptorPath(parentHandle.fd, finalName), - ); - if (lstatOptional(tempPath) || !lstatOptional(finalPath)) { + // link() reports the race itself; re-deriving that verdict from a later pair + // of stats would be both slower and weaker. + if (!publishNoReplace(tempRef, finalRef)) { throw new Error('Generated-plan publication was refused because the destination raced'); } + // link() creates a directory entry, so it needs the parent fsync that rename + // needed: the file's own bytes were fsynced through tempFd before this point, + // and this makes the name that now reaches them durable too. Skipping it is + // the step write-file-atomic omits and maildir, git and atomicwrites all + // mandate. + // + // Honest limitation: on macOS fsync is not a write barrier — the durable + // primitive there is fcntl(F_FULLFSYNC), which Node does not expose. A + // macOS plan write is therefore as durable as fsync makes it and no more. fs.fsyncSync(parentHandle.fd); - testHooks?.afterPublication?.({ fd: parentHandle.fd, finalPath }); - testHooks?.afterRename?.({ fd: parentHandle.fd, finalPath }); + testHooks?.afterPublication?.({ fd: parentHandle.fd, finalPath: finalRef.path }); validatePlanParent(parentHandle); validatePlanParent(vaultHandle); - validateCommittedPlan(finalPath, tempFd, expectedTemp, testHooks); + validateCommittedPlan(finalRef, tempFd, expectedTemp, testHooks); const receipt = { generated_plan_path: generatedPlan, bytes_written: contents.length }; if (priorBackup) receipt.prior_plan_backup_git_path = priorBackup.gitPath; return receipt; @@ -1848,6 +2133,11 @@ export function snapshotEvidence({ const headGuards = captureHeadGuards(repo); const dirty = initialDirty.records; const mutationGuards = []; + // Per-snapshot walk state: `absenceCache` owns every descriptor an absence + // anchor holds, deduplicated by repo-relative prefix and closed exactly once + // below; `guardedDirectories` keeps parent guarding to one stat per directory. + const absenceCache = new Map(); + const walkState = { absenceCache, guardedDirectories: new Set() }; try { testHooks?.afterAnchorCapture?.({ headCommit: head }); @@ -1862,7 +2152,9 @@ export function snapshotEvidence({ testHooks?.afterGitLayerLoad?.({ headCommit: head }); const globalEntries = [...dirty.values()] .filter((record) => record.path !== generatedPlan) - .map((record) => materializeRecord(repo, record, layers, mutationGuards, testHooks)); + .map((record) => + materializeRecord(repo, record, layers, mutationGuards, testHooks, walkState), + ); const citedEntries = [...normalizedCitations].sort(compareUtf8).map((repoPath) => { const status = dirty.get(repoPath) ?? { path: repoPath, @@ -1871,7 +2163,7 @@ export function snapshotEvidence({ rename_to: null, has_untracked: false, }; - const entry = materializeRecord(repo, status, layers, mutationGuards, testHooks); + const entry = materializeRecord(repo, status, layers, mutationGuards, testHooks, walkState); const present = Object.values(entry.object_kind).some((kind) => kind !== ABSENT); if (!present) entry.state = ABSENT; else if (entry.state === 'clean' && entry.object_kind.untracked !== ABSENT) { @@ -1906,21 +2198,13 @@ export function snapshotEvidence({ throw new Error(`${guard.absolute} changed before evidence materialization completed`); } } else if (guard.type === 'absence') { + // statIdentity is a strict superset of stableDirectoryIdentity on the + // same stat, so comparing both could only ever fire together. const parent = fs.fstatSync(guard.fd, { bigint: true }); - if ( - !parent.isDirectory() || - stableDirectoryIdentity(parent) !== guard.parentIdentity || - statIdentity(parent) !== guard.parentMutationIdentity - ) { + if (!parent.isDirectory() || statIdentity(parent) !== guard.parentMutationIdentity) { throw new Error(`Absence anchor changed for ${guard.repoPath}`); } - try { - fs.lstatSync(descriptorPath(guard.fd, guard.childName), { bigint: true }); - } catch (error) { - if (error?.code === 'ENOENT') continue; - throw error; - } - throw new Error(`${guard.repoPath} appeared before evidence materialization completed`); + anchoringBackend().verifyAbsentChild(guard); } } for (const guard of headGuards) verifyControlFile(guard); @@ -1955,12 +2239,10 @@ export function snapshotEvidence({ cited_path_manifest: citedEntries, }; } finally { - const closed = new Set(); - for (const guard of mutationGuards) { - if (guard.type !== 'absence' || closed.has(guard.fd)) continue; - closed.add(guard.fd); + // One entry per distinct anchored directory, so one close per descriptor. + for (const handle of absenceCache.values()) { try { - fs.closeSync(guard.fd); + fs.closeSync(handle.fd); } catch { // Preserve the primary snapshot result/error. } diff --git a/gitnexus/src/cli/ai-context.ts b/gitnexus/src/cli/ai-context.ts index f3ab38cf1..c7b3a934f 100644 --- a/gitnexus/src/cli/ai-context.ts +++ b/gitnexus/src/cli/ai-context.ts @@ -9,8 +9,9 @@ import fs from 'fs/promises'; import path from 'path'; import { fileURLToPath } from 'url'; -import { type GeneratedSkillInfo } from './skill-gen.js'; +import { type GeneratedSkillInfo } from './generated-skill.js'; import { STANDARD_SKILL_CATALOG } from './standard-skills.js'; +import { isEnoent } from './editor-targets.js'; import { logger } from '../core/logger.js'; // ESM equivalent of __dirname @@ -42,6 +43,8 @@ export interface AIContextOptions { * "no PDG layer" note, so advertising it on a non-`--pdg` index is noise. */ hasPdg?: boolean; + /** Whether this index includes opt-in Spring Actuator runtime evidence. */ + hasSpringActuator?: boolean; } const GITNEXUS_START_MARKER = ''; @@ -136,6 +139,8 @@ export interface GitNexusContentOptions { * line below — false (default) omits it, so a non-pdg index doesn't advertise * a tool that only returns a "no PDG layer" note. */ hasPdg?: boolean; + /** Whether Route nodes may carry Spring Actuator runtime evidence. */ + hasSpringActuator?: boolean; } export function generateGitNexusContent( @@ -151,13 +156,17 @@ export function generateGitNexusContent( runnerPath = '.gitnexus/run.cjs', defaultBranch = 'main', hasPdg = false, + hasSpringActuator = false, } = opts; const generatedRows = generatedSkills && generatedSkills.length > 0 ? generatedSkills .map( (s) => - `| Work in the ${s.label} area (${s.symbolCount} symbols) | \`.claude/skills/${s.name}/SKILL.md\` |`, + // The per-cluster count is as volatile as the header parenthetical, + // so --no-stats drops it too (#2907) — otherwise the flag that + // promises "omit volatile symbol counts" left a churning one behind. + `| Work in the ${s.label} area${noStats ? '' : ` (${s.symbolCount} symbols)`} | \`.claude/skills/${s.name}/SKILL.md\` |`, ) .join('\n') : ''; @@ -195,24 +204,39 @@ ${tableBody}` `No \`${runnerPath}\` yet? Bootstrap with \`npx\`, \`bunx\`, or \`pnpm dlx\` — ` + 'e.g. `bunx gitnexus@latest analyze` (npm 11 npx crash; #1939).'; + // This block is injected into every user's repo and its total size is capped + // by test (ai-context.test.ts, #856) — a new bullet or clause has to be paid + // for by trimming an existing one. + // + // The detect_changes bullet carries the degraded-result rule (#2915): a run + // that sets `partial` (a graph query failed) or `truncated` (the changed-symbol + // listing was capped) is not the pre-commit gate passing, and `partial` pairs + // routinely with changed_count:0 — the exact shape that printed "No changes + // detected." and exited 0 on a broken analysis. Same reasoning as the + // `risk: UNKNOWN` bullet below: the tool could not answer, so its zero is not + // an all-clear. return `${GITNEXUS_START_MARKER} # GitNexus — Code Intelligence -This project is indexed by GitNexus as **${projectName}**${noStats ? '' : ` (${stats.nodes || 0} symbols, ${stats.edges || 0} relationships, ${stats.processes || 0} execution flows)`}. Use GitNexus graph tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **${projectName}**${noStats ? '' : ` (${stats.nodes || 0} symbols, ${stats.edges || 0} relationships, ${stats.processes || 0} execution flows)`}. -> Index stale? Run \`${runner} analyze\` from the project root — it auto-selects an available runner. ${bootstrapNote} +> Index stale? Run \`${runner} analyze --index-only\` from the project root — it auto-selects an available runner. ${bootstrapNote} ## 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: \` — 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 --repo .\`.` : '' } -- **MUST analyze graph changes before committing.** Use \`detect_changes({scope: "all"})\` (MCP) or \`${runner} detect-changes --scope all --repo .\` (CLI fallback). 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. -- When exploring unfamiliar code, use \`query({search_query: "concept"})\` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. -- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use \`context({name: "symbolName"})\`. +- **MUST 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 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. +- **MUST use \`query({search_query: "concept"})\` for concepts/flows, \`context({name: "symbolName"})\` for a named symbol, or \`impact\` for blast radius, on read-only callers, dependencies, imports, or execution flow.** Graph first; text search only for empty/\`UNKNOWN\`/literals.${ + hasSpringActuator + ? '\n- Spring Actuator runtime evidence is enabled. A Route is authoritative only when `runtimeConfirmed === true`; `runtimeSource` is provenance and may also describe conflicts. Snapshot values are never persisted.' + : '' + } - For security review, \`explain({target: "fileOrSymbol"})\` lists taint findings (source→sink flows; needs \`analyze --pdg\`).${ hasPdg ? `\n- For control/data dependence, \`pdg_query({mode: "controls", target: "fileOrSymbol"})\` answers "under what condition does X run?" (CDG, incl. guard clauses) and \`pdg_query({mode: "flows", target, variable})\` traces "where does variable Y flow?" (REACHING_DEF). \`--pdg\` layer.` @@ -222,7 +246,7 @@ This project is indexed by GitNexus as **${projectName}**${noStats ? '' : ` (${s ## Never Do - NEVER edit a function, class, or method before MCP/CLI impact analysis. -- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis. +- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis, and never read \`UNKNOWN\` as an all-clear — it means the walk could not answer, which is the one verdict that requires confirming by other means. - NEVER rename symbols with find-and-replace — use \`rename\` which understands the call graph. - NEVER commit before MCP/CLI graph change analysis. @@ -266,11 +290,33 @@ async function fileExists(filePath: string): Promise { } } +/** + * Replace the block's volatile counts — the header parenthetical and the + * per-cluster symbol counts in the skills table — with fixed placeholders, so + * two renderings that differ only in those numbers compare equal. + * + * Placeholders rather than deletions: `--no-stats` REMOVES the parenthetical, + * which must still be written through. Deleting instead of substituting would + * make a with-counts block and a without-counts block compare equal, and the + * flag would silently stop taking effect on an already-injected file. + */ +function stripVolatileCounts(section: string): string { + return section + .replace(/ \(\d+ symbols, \d+ relationships, \d+ execution flows\)/g, ' ()') + .replace(/ \(\d+ symbols\)/g, ' ()'); +} + /** * Create or update GitNexus section in a file * - If file doesn't exist: create with GitNexus content * - If file exists without GitNexus section: append - * - If file exists with GitNexus section: replace that section + * - If file exists with GitNexus section: replace that section, UNLESS the only + * delta is the volatile counts (#2907). AGENTS.md and CLAUDE.md are the agent + * guides teams commit, and the counts move with any code change, so a + * count-only rewrite dirties a tracked file on every reindex for no reader + * benefit. Live counts stay available from `gitnexus status` and + * `gitnexus://repo/{name}/context`; the committed block keeps whichever + * numbers it was last materially updated with. */ async function upsertGitNexusSection( filePath: string, @@ -282,7 +328,10 @@ async function upsertGitNexusSection( const exists = await fileExists(filePath); if (!exists) { - await fs.writeFile(filePath, content, 'utf-8'); + // Same `.trim() + '\n'` shape the update paths write. Creating without the + // trailing newline made the NEXT analyze dirty a freshly committed file + // even at unchanged counts, purely to append it (#2907). + await fs.writeFile(filePath, content.trim() + '\n', 'utf-8'); return 'created'; } @@ -343,6 +392,11 @@ async function upsertGitNexusSection( if (statsPattern.test(existingSection)) { const updatedSection = existingSection.replace(statsPattern, statsLine); + // Count-only delta — leave the committed lean block alone (#2907). A + // project rename, or --no-stats dropping the parenthetical, still writes. + if (stripVolatileCounts(updatedSection) === stripVolatileCounts(existingSection)) { + return 'preserved'; + } const before = existingContent.substring(0, startIdx); const after = existingContent.substring(endIdx + GITNEXUS_END_MARKER.length); await fs.writeFile(filePath, (before + updatedSection + after).trim() + '\n', 'utf-8'); @@ -354,7 +408,11 @@ async function upsertGitNexusSection( return 'preserved'; } - // No keep marker — replace existing section with full verbose content + // No keep marker — replace existing section with full verbose content, + // unless the counts are the only thing that moved (#2907). + if (stripVolatileCounts(existingSection) === stripVolatileCounts(content)) { + return 'preserved'; + } const before = existingContent.substring(0, startIdx); const after = existingContent.substring(endIdx + GITNEXUS_END_MARKER.length); const newContent = before + content + after; @@ -383,17 +441,84 @@ export async function shouldMirrorSkillsToAgents(repoPath: string): Promise { + try { + return await fs.readFile(filePath, 'utf-8'); + } catch (err) { + if (isEnoent(err)) return null; + throw err; + } +} + +function skillBytesDiverge(existing: string | null, bundled: string): boolean { + return existing !== null && existing !== bundled; +} + +/** Write bundled skill bytes unless an existing file already differs. */ +async function writeSkillUnlessDivergent(filePath: string, content: string): Promise { + const existing = await readUtf8IfPresent(filePath); + if (skillBytesDiverge(existing, content)) { + logger.warn(`Preserved customized skill ${filePath}; ${SKILL_PRESERVE_HINT}.`); + return true; + } + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, content, 'utf-8'); + return false; +} + +async function inspectLegacySkillDir( + legacyDir: string, +): Promise<{ nestedExisting: string | null; hasSiblings: boolean } | null> { + let entries: string[]; + try { + entries = await fs.readdir(legacyDir); + } catch (err) { + if (isEnoent(err)) return null; + throw err; + } + const nestedExisting = entries.includes('SKILL.md') + ? await fs.readFile(path.join(legacyDir, 'SKILL.md'), 'utf-8') + : null; + return { + nestedExisting, + hasSiblings: entries.some((entry) => entry !== 'SKILL.md'), + }; +} + +function formatSkillInstallLine( + prefix: string, + total: number, + preserved: number, + allWrittenSuffix: string, + partialSuffix: string, +): string { + if (preserved > 0) { + return `${prefix} (${total - preserved} written, ${preserved} ${partialSuffix})`; + } + return `${prefix} (${total} ${allWrittenSuffix})`; +} + /** * Install GitNexus skills as direct children of .claude/skills/ * Works natively with Claude Code, Cursor, and GitHub Copilot. * Mirrored to .agents/skills/ when .agents/ exists. */ -async function installSkills( - repoPath: string, -): Promise<{ skills: string[]; agentsMirror: boolean }> { +async function installSkills(repoPath: string): Promise<{ + skills: string[]; + agentsMirror: boolean; + claudePreserved: number; + agentsPreserved: number; + legacyPreserved: number; +}> { const skillsDir = path.join(repoPath, '.claude', 'skills'); const legacySkillsDir = path.join(skillsDir, 'gitnexus'); const installedSkills: string[] = []; + let claudePreserved = 0; + let agentsPreserved = 0; + let legacyPreserved = 0; const agentsMirror = await shouldMirrorSkillsToAgents(repoPath); for (const skill of STANDARD_SKILL_CATALOG.filter( @@ -403,9 +528,6 @@ async function installSkills( const skillPath = path.join(skillDir, 'SKILL.md'); try { - // Create skill directory - await fs.mkdir(skillDir, { recursive: true }); - // Try to read from package skills directory const packageSkillPath = path.join(__dirname, '..', '..', 'skills', `${skill.name}.md`); let skillContent: string; @@ -427,14 +549,13 @@ Use GitNexus tools to accomplish this task. `; } - await fs.writeFile(skillPath, skillContent, 'utf-8'); + if (await writeSkillUnlessDivergent(skillPath, skillContent)) claudePreserved += 1; // Mirror to .agents/skills/ for agents that read repo-local skills if (agentsMirror) { try { - const agentsSkillDir = path.join(repoPath, '.agents', 'skills', skill.name); - await fs.mkdir(agentsSkillDir, { recursive: true }); - await fs.writeFile(path.join(agentsSkillDir, 'SKILL.md'), skillContent, 'utf-8'); + const agentsSkillPath = path.join(repoPath, '.agents', 'skills', skill.name, 'SKILL.md'); + if (await writeSkillUnlessDivergent(agentsSkillPath, skillContent)) agentsPreserved += 1; } catch (err) { logger.warn({ err }, `Warning: Could not mirror skill ${skill.name} to .agents/skills:`); } @@ -446,7 +567,20 @@ Use GitNexus tools to accomplish this task. // deep. Remove only the child owned by this installer; unknown siblings // under the legacy grouping directory may be user-authored and survive. try { - await fs.rm(path.join(legacySkillsDir, skill.name), { recursive: true, force: true }); + const legacyDir = path.join(legacySkillsDir, skill.name); + const nestedSkill = path.join(legacyDir, 'SKILL.md'); + const leftover = await inspectLegacySkillDir(legacyDir); + if (leftover !== null && skillBytesDiverge(leftover.nestedExisting, skillContent)) { + logger.warn(`Preserved customized skill ${nestedSkill}; ${SKILL_PRESERVE_HINT}.`); + legacyPreserved += 1; + } else if (leftover?.hasSiblings) { + logger.warn( + `Preserved legacy skill directory ${legacyDir} because it contains operator-owned files.`, + ); + legacyPreserved += 1; + } else if (leftover !== null) { + await fs.rm(legacyDir, { recursive: true, force: true }); + } } catch (err) { logger.warn({ err }, `Warning: Could not remove legacy skill ${skill.name}:`); } @@ -456,7 +590,13 @@ Use GitNexus tools to accomplish this task. } } - return { skills: installedSkills, agentsMirror }; + return { + skills: installedSkills, + agentsMirror, + claudePreserved, + agentsPreserved, + legacyPreserved, + }; } /** @@ -502,6 +642,7 @@ export async function generateAIContextFiles( runnerPath, defaultBranch: options?.defaultBranch ?? 'main', hasPdg: options?.hasPdg ?? false, + hasSpringActuator: options?.hasSpringActuator ?? false, }); const createdFiles: string[] = []; @@ -534,12 +675,37 @@ export async function generateAIContextFiles( // Install standard skills directly under .claude/skills/ (unless --skip-skills) if (!options?.skipSkills) { - const { skills: installedSkills, agentsMirror } = await installSkills(repoPath); + const { + skills: installedSkills, + agentsMirror, + claudePreserved, + agentsPreserved, + legacyPreserved, + } = await installSkills(repoPath); if (installedSkills.length > 0) { - createdFiles.push(`.claude/skills/gitnexus-*/ (${installedSkills.length} skills)`); + createdFiles.push( + formatSkillInstallLine( + '.claude/skills/gitnexus-*/', + installedSkills.length, + claudePreserved, + 'skills', + 'preserved', + ), + ); if (agentsMirror) { createdFiles.push( - `.agents/skills/gitnexus-*/ (${installedSkills.length} skills mirrored for .agents)`, + formatSkillInstallLine( + '.agents/skills/gitnexus-*/', + installedSkills.length, + agentsPreserved, + 'skills mirrored for .agents', + 'preserved for .agents', + ), + ); + } + if (legacyPreserved > 0) { + createdFiles.push( + `.claude/skills/gitnexus// (legacy directories preserved: ${legacyPreserved})`, ); } } diff --git a/gitnexus/src/cli/analyze-config.ts b/gitnexus/src/cli/analyze-config.ts index 048ecc52f..3d040fac1 100644 --- a/gitnexus/src/cli/analyze-config.ts +++ b/gitnexus/src/cli/analyze-config.ts @@ -30,7 +30,8 @@ import fs from 'node:fs'; import path from 'node:path'; -import type { AnalyzeOptions } from './analyze.js'; +import { readRepoControlFile } from '../config/repo-control-file.js'; +import type { AnalyzeOptions } from './analyze-options.js'; export const GITNEXUS_RC_FILENAME = '.gitnexusrc'; @@ -59,7 +60,8 @@ type ValueKind = | 'string-array' | 'numeric-string' | 'embeddings' - | 'branch'; + | 'branch' + | 'path'; interface KeySpec { /** The `AnalyzeOptions` field this config key normalizes into. */ @@ -107,6 +109,9 @@ const KEY_SPECS: Record = { // built-in convention set, is otherwise invisible to route_map consumers. // Listing it here adds it to the cross-file consumer scan. fetchWrappers: { target: 'fetchWrappers', kind: 'string-array' }, + // Explicit local Actuator snapshot input (#2418). The path itself is safe in + // project config; payload contents are never copied into the graph wholesale. + springActuator: { target: 'springActuator', kind: 'path' }, // Auth token AND dims are intentionally CLI/env-only — no embeddingAuthToken // or embeddingDims key here: // - the token keeps secrets out of a committed .gitnexusrc; @@ -225,6 +230,17 @@ const normalizeValue = (kind: ValueKind, value: unknown, key: string): unknown = throw new GitNexusRcError(`${source} must be a string branch name.`); } return validateBranchName(value, source); + case 'path': { + if (typeof value !== 'string') { + throw new GitNexusRcError(`${source} must be a file or directory path.`); + } + const trimmed = value.trim(); + if (!trimmed) { + throw new GitNexusRcError(`${source} must not be empty.`); + } + assertNoHiddenChars(trimmed, source); + return trimmed; + } case 'string': { if (typeof value !== 'string') { throw new GitNexusRcError(`${source} must be a string.`); @@ -370,7 +386,6 @@ const normalizeLevel = ( */ export function loadAnalyzeConfig(repoRoot: string): Partial | undefined { const filePath = path.join(repoRoot, GITNEXUS_RC_FILENAME); - let raw: string; try { raw = fs.readFileSync(filePath, 'utf-8'); @@ -379,6 +394,25 @@ export function loadAnalyzeConfig(repoRoot: string): Partial | 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 | 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 { + 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 diff --git a/gitnexus/src/cli/analyze-options.ts b/gitnexus/src/cli/analyze-options.ts new file mode 100644 index 000000000..a749589f1 --- /dev/null +++ b/gitnexus/src/cli/analyze-options.ts @@ -0,0 +1,140 @@ +/** + * CLI-facing `analyze` option shape. + * + * This is the *flag* shape: it mirrors what Commander parses off the command + * line and what `.gitnexusrc` may set, before `analyze` translates it into the + * core orchestrator's own `AnalyzeOptions` (`core/run-analyze.ts`) — a + * different, deliberately separate interface (`stats` here vs `noStats` + * there, `embeddings?: boolean | string` here vs a resolved + * `embeddingsNodeLimit` there). + * + * It lives in this leaf module because both `analyze.ts` (which consumes the + * flags) and `analyze-config.ts` (which maps `.gitnexusrc` keys onto them) + * need it, and `analyze.ts` already imports the config loader — a type import + * back the other way put the two files, plus `core/run-analyze.ts`, in an + * 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; + /** + * Embedding generation toggle. Commander parses `--embeddings [limit]` as: + * - `undefined` when the flag is omitted + * - `true` when passed without an argument (use default 50K node cap) + * - a string when passed with an argument (`--embeddings 0` disables the + * cap, `--embeddings ` uses `` as the cap) + */ + embeddings?: boolean | string; + /** + * Explicitly drop existing embeddings on rebuild instead of preserving + * them. Without this flag, a routine `analyze` keeps any embeddings + * already present in the index even when `--embeddings` is omitted. + */ + dropEmbeddings?: boolean; + skills?: boolean; + verbose?: boolean; + /** Skip AGENTS.md and CLAUDE.md gitnexus block updates. */ + skipAgentsMd?: boolean; + /** + * Build the control-flow-graph / PDG substrate (#2081 M1). Opt-in; off by + * default. Threaded to both the worker (CFG build) and scope-resolution + * (BasicBlock/CFG emit). + */ + pdg?: boolean; + /** + * Stats inclusion in AGENTS.md and CLAUDE.md. + * + * Commander.js represents `--no-stats` as `stats: boolean` (default + * `true`; `false` when the user passes `--no-stats`), NOT as + * `noStats: boolean`. Reading the negated form would always be + * `undefined` and the flag would silently no-op (#1477). Consumers + * that want "did the user request --no-stats?" should compare with + * `=== false` to distinguish the explicit-off case from the + * default-on case. + */ + stats?: boolean; + /** + * Opt-in auto-commit of any AGENTS.md/CLAUDE.md changes this `analyze` run + * makes. Scoped to only those two files (never `git add -A`); no-ops + * silently if neither exists, neither changed, or the commit step itself + * fails (e.g. no git identity configured). See #2639. + */ + selfCommit?: boolean; + /** Skip installing standard GitNexus skill files directly under .claude/skills/. */ + skipSkills?: boolean; + /** + * Default branch for the generated regression-compare example (#243). From + * `--default-branch`; may also be supplied via `.gitnexusrc`. Resolved to a + * concrete branch (CLI > `.gitnexusrc` > auto-detected origin/HEAD > "main") + * before being threaded into the generated AGENTS.md / CLAUDE.md content. + */ + defaultBranch?: string; + /** + * Index-branch selector (#2106). From `--branch`. Distinct from + * `defaultBranch` (cosmetic base_ref): this routes the index to a per-branch + * slot. NOT sourced from `.gitnexusrc` — the `.gitnexusrc` `branch` key is an + * alias for `defaultBranch` and must not change index placement. Defaults to + * the checked-out branch inside `runFullAnalysis` when omitted. + */ + branch?: string; + /** Pure index mode: skip all file injection (AGENTS.md, CLAUDE.md, skills). */ + indexOnly?: boolean; + /** Index the folder even when no .git directory is present. */ + skipGit?: boolean; + /** + * Override the default basename-derived registry `name` with a + * user-supplied alias (#829). Disambiguates repos whose paths share a + * basename. Persisted — subsequent re-analyses of the same path without + * `--name` preserve the alias. + */ + name?: string; + /** + * Allow registration even when another path already uses the same + * `--name` alias (#829). Intentionally a distinct flag from `--force` + * because the user may want to coexist under the same name WITHOUT + * paying the cost of a pipeline re-index. Maps to registerRepo's + * `allowDuplicateName` option end-to-end. + */ + allowDuplicateName?: boolean; + /** + * Override the walker's large-file skip threshold (#991). Value in KB; + * clamped downstream to the tree-sitter 32 MB ceiling. Sets + * `GITNEXUS_MAX_FILE_SIZE` for the rest of the pipeline. + */ + maxFileSize?: string; + /** Override worker sub-batch idle timeout in seconds. */ + workerTimeout?: string; + /** Control LadybugDB WAL auto-checkpoint threshold during analyze. */ + walCheckpointThreshold?: string; + /** Parse worker pool size (>=1); 0 is rejected (no sequential mode). */ + workers?: string; + embeddingThreads?: string; + embeddingBatchSize?: string; + embeddingSubBatchSize?: string; + embeddingDevice?: string; + /** + * Extra fetch-wrapper function names to treat as HTTP consumers (#1589/#1852 + * residual). Supplied via `.gitnexusrc` `fetchWrappers: [...]`. Threaded into + * the routes phase, where the cross-file consumer scan unions them with the + * auto-detected `fetch()` wrappers so a custom/axios-based wrapper named + * outside the built-in convention still produces `route_map` consumers. + */ + fetchWrappers?: string[]; + /** + * Explicit local Spring Boot Actuator snapshot input (#2418). Accepts a JSON + * bundle or a directory containing endpoint JSON files. Disabled by default. + */ + springActuator?: string; + /** OpenAI-compatible embeddings base URL (incl. /v1). Overrides GITNEXUS_EMBEDDING_URL. */ + embeddingBaseUrl?: string; + /** Embedding model name. Overrides GITNEXUS_EMBEDDING_MODEL. */ + embeddingModel?: string; + /** Bearer token for the embeddings endpoint. Overrides GITNEXUS_EMBEDDING_API_KEY. Never logged. */ + embeddingAuthToken?: string; + /** Embedding vector dimensions (positive integer string). Overrides GITNEXUS_EMBEDDING_DIMS. */ + embeddingDims?: string; +} diff --git a/gitnexus/src/cli/analyze-watch.ts b/gitnexus/src/cli/analyze-watch.ts new file mode 100644 index 000000000..2e4744bc5 --- /dev/null +++ b/gitnexus/src/cli/analyze-watch.ts @@ -0,0 +1,504 @@ +/** Local incremental watch (`gitnexus analyze --watch`). Remote auto-sync lives in `auto-sync.ts`. */ +import path from 'node:path'; +import fs from 'node:fs/promises'; +import { watch, type FSWatcher } from 'chokidar'; +import { createWatchIgnorePredicate } from '../config/ignore-service.js'; +import { + analyzeFailureMayHaveMutatedLiveIndex, + runFullAnalysis, + type AnalyzeOptions as CoreAnalyzeOptions, + type AnalyzeResult, +} from '../core/run-analyze.js'; +import { getGitRoot, hasGitDir } from '../storage/git.js'; +import type { AnalyzerRunnerIdentity } from '../storage/repo-manager.js'; +import { GITNEXUS_DIR } from '../storage/repo-meta.js'; +import { + loadAnalyzeConfigStrict, + mergeAnalyzeOptions, + validateBranchName, +} from './analyze-config.js'; +import type { AnalyzeOptions } from './analyze-options.js'; +import { ensureHeap } from './analyze.js'; +import { cliError, cliInfo, cliWarn } from './cli-message.js'; +import { + WATCH_FULL_REFRESH_PATH, + WatchRefreshQueue, + type WatchRefreshError, +} from './watch-queue.js'; + +const DEFAULT_DEBOUNCE_MS = 300; +const MAX_TIMER_DELAY_MS = 2_147_483_647; +const MAX_FILE_SIZE_KB = 32 * 1024; +const TRANSIENT_WATCH_ERROR_CODES = new Set(['EACCES', 'ENOENT', 'ENOTDIR', 'EPERM']); + +export type WatchCliOptions = AnalyzeOptions; + +function posixWatchPath(filePath: string): string { + return filePath.replace(/\\/g, '/').replace(/^\.\/+/, ''); +} + +export function isRelevantWatchPath(filePath: string): boolean { + const normalized = posixWatchPath(filePath); + return ( + normalized.length > 0 && + normalized !== '.' && + !normalized.startsWith('../') && + !path.posix.isAbsolute(normalized) && + !path.win32.isAbsolute(filePath) + ); +} + +function isIgnoreControlPath(filePath: string): boolean { + const normalized = posixWatchPath(filePath); + return normalized === '.gitignore' || normalized === '.gitnexusignore'; +} + +function isConfigControlPath(filePath: string): boolean { + return posixWatchPath(filePath) === '.gitnexusrc'; +} + +function isAnalyzerOwnedWatchPath(filePath: string): boolean { + const normalized = posixWatchPath(filePath).replace(/\/+$/, ''); + return normalized === GITNEXUS_DIR || normalized.startsWith(`${GITNEXUS_DIR}/`); +} + +function repoRelativeWatchPath(repoPath: string, candidate: string): string | null { + const relative = path.relative(repoPath, candidate).replace(/\\/g, '/'); + if (!relative || relative.startsWith('../') || path.isAbsolute(relative)) return null; + return relative; +} + +export interface WatchEnvironmentBaseline { + readonly maxFileSize: string | undefined; + readonly workerTimeout: string | undefined; + readonly verbose: string | undefined; +} + +function setEnvironment(name: string, value: string | undefined): void { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; +} + +function positiveInteger( + value: string | undefined, + flag: string, + maximum?: number, +): number | undefined { + if (value === undefined) return undefined; + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 1) + throw new Error(`${flag} must be a positive integer`); + if (maximum !== undefined && parsed > maximum) { + throw new Error(`${flag} must not exceed ${maximum}`); + } + return parsed; +} + +export async function resolveWatchOptions( + repoPath: string, + cli: WatchCliOptions, + baseline: WatchEnvironmentBaseline, + reportIgnoredConfig: (names: readonly string[]) => void = () => {}, +): Promise { + const config = (await loadAnalyzeConfigStrict(repoPath)) ?? {}; + const merged = mergeAnalyzeOptions(cli, config); + const unsupported = [ + ['--force', cli.force], + ['--repair-fts', cli.repairFts], + ['--embeddings', cli.embeddings], + ['--drop-embeddings', cli.dropEmbeddings], + ['--skills', cli.skills], + ['--default-branch', cli.defaultBranch], + ['--skip-agents-md', cli.skipAgentsMd], + ['--skip-skills', cli.skipSkills], + ['--no-stats', cli.stats === false], + ['--self-commit', cli.selfCommit], + ['--index-only', cli.indexOnly], + ['--skip-git', cli.skipGit], + ['--spring-actuator', cli.springActuator], + ['walCheckpointThreshold', cli.walCheckpointThreshold], + ['embeddingThreads', cli.embeddingThreads], + ['embeddingBatchSize', cli.embeddingBatchSize], + ['embeddingSubBatchSize', cli.embeddingSubBatchSize], + ['embeddingDevice', cli.embeddingDevice], + ['embeddingBaseUrl', cli.embeddingBaseUrl], + ['embeddingModel', cli.embeddingModel], + ['--embedding-auth-token', cli.embeddingAuthToken], + ['--embedding-dims', cli.embeddingDims], + ].filter(([, value]) => value !== undefined && value !== false); + if (unsupported.length > 0) { + throw new Error( + `analyze --watch does not support ${unsupported.map(([name]) => name).join(', ')}`, + ); + } + reportIgnoredConfig( + [ + ['embeddings', config.embeddings], + ['dropEmbeddings', config.dropEmbeddings], + ['defaultBranch', config.defaultBranch], + ['skipAgentsMd', config.skipAgentsMd !== undefined], + ['skipSkills', config.skipSkills !== undefined], + ['stats', config.stats !== undefined], + ['springActuator', config.springActuator], + ['walCheckpointThreshold', config.walCheckpointThreshold], + ['embeddingThreads', config.embeddingThreads], + ['embeddingBatchSize', config.embeddingBatchSize], + ['embeddingSubBatchSize', config.embeddingSubBatchSize], + ['embeddingDevice', config.embeddingDevice], + ['embeddingBaseUrl', config.embeddingBaseUrl], + ['embeddingModel', config.embeddingModel], + ] + .filter(([, value]) => value !== undefined && value !== false) + .map(([name]) => String(name)), + ); + const branch = + merged.branch === undefined ? undefined : validateBranchName(merged.branch, '--branch'); + const workerPoolSize = positiveInteger(merged.workers, '--workers'); + const workerTimeoutSeconds = positiveInteger(merged.workerTimeout, 'workerTimeout'); + const maxFileSize = positiveInteger(merged.maxFileSize, 'maxFileSize', MAX_FILE_SIZE_KB); + + setEnvironment( + 'GITNEXUS_MAX_FILE_SIZE', + maxFileSize === undefined ? baseline.maxFileSize : String(maxFileSize), + ); + if (workerTimeoutSeconds !== undefined) { + process.env.GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS = String(workerTimeoutSeconds * 1000); + } else { + setEnvironment('GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS', baseline.workerTimeout); + } + setEnvironment('GITNEXUS_VERBOSE', merged.verbose ? '1' : baseline.verbose); + + return { + pdg: merged.pdg, + branch, + registryName: merged.name, + allowDuplicateName: merged.allowDuplicateName, + workerPoolSize, + fetchWrappers: merged.fetchWrappers, + skipAgentsMd: true, + skipSkills: true, + noStats: true, + atomicIncremental: process.platform !== 'win32', + }; +} + +function refreshSummary( + result: AnalyzeResult, + observedPaths: readonly string[], + durationMs: number, + lastSuccessfulRefreshAt: string, +): string { + const measured = result.incrementalStats; + const changed = measured?.changedFiles ?? (result.alreadyUpToDate ? 0 : observedPaths.length); + const reparsed = + measured?.reparsedFiles ?? + (typeof result.pipelineResult?.reparsedFileCount === 'number' + ? result.pipelineResult.reparsedFileCount + : 0); + const dependents = measured?.affectedDependents ?? 0; + const mode = measured?.writeMode ?? (result.alreadyUpToDate ? 'no-op' : 'full'); + return ( + `Refresh complete: ${changed} changed, ${reparsed} re-parsed, ` + + `${dependents} affected dependent(s), ${durationMs}ms, ${mode}; ` + + `last success ${lastSuccessfulRefreshAt}` + ); +} + +async function waitUntilReady(watcher: FSWatcher): Promise { + await new Promise((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; + readonly close: () => Promise; +} + +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, + onError: WatchRefreshError, + onWatcherError: (error: unknown) => void = (error) => onError(error, []), +): Promise { + 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 { + 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((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); + } +} diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index 8267082c9..beb553e01 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -50,6 +50,7 @@ import { validateBranchName, GitNexusRcError, } from './analyze-config.js'; +import type { AnalyzeOptions } from './analyze-options.js'; import { runFullAnalysis } from '../core/run-analyze.js'; import { getRuntimeFingerprint } from '../core/platform/capabilities.js'; import { getMaxFileSizeBannerMessage } from '../core/ingestion/utils/max-file-size.js'; @@ -362,6 +363,7 @@ interface RespawnExit { stdout?: string; stderr?: string; message?: string; + forwardedSignal?: NodeJS.Signals; } const appendOutputTail = (tail: string, chunk: unknown): string => { @@ -394,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); @@ -547,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 { +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 { // 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 @@ -589,6 +611,13 @@ async function ensureHeap(): Promise { }; 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( @@ -639,6 +668,8 @@ const ANALYZE_CLI_ENV_KEYS = [ 'GITNEXUS_EMBEDDING_SUB_BATCH_SIZE', 'GITNEXUS_EMBEDDING_DEVICE', 'GITNEXUS_ANALYZE_PROGRESS_ACTIVE', + 'GITNEXUS_ANALYZER_IDENTITY_IN_PROCESS_GUARDS', + 'GITNEXUS_RESOLVE_DEF_GRAPH_ID_MEMO', 'GITNEXUS_EMBEDDING_URL', 'GITNEXUS_EMBEDDING_MODEL', 'GITNEXUS_EMBEDDING_API_KEY', @@ -661,121 +692,14 @@ const restoreAnalyzeEnv = (snap: AnalyzeEnvSnapshot): void => { } }; -export interface AnalyzeOptions { - force?: boolean; - repairFts?: boolean; - /** - * Embedding generation toggle. Commander parses `--embeddings [limit]` as: - * - `undefined` when the flag is omitted - * - `true` when passed without an argument (use default 50K node cap) - * - a string when passed with an argument (`--embeddings 0` disables the - * cap, `--embeddings ` uses `` as the cap) - */ - embeddings?: boolean | string; - /** - * Explicitly drop existing embeddings on rebuild instead of preserving - * them. Without this flag, a routine `analyze` keeps any embeddings - * already present in the index even when `--embeddings` is omitted. - */ - dropEmbeddings?: boolean; - skills?: boolean; - verbose?: boolean; - /** Skip AGENTS.md and CLAUDE.md gitnexus block updates. */ - skipAgentsMd?: boolean; - /** - * Build the control-flow-graph / PDG substrate (#2081 M1). Opt-in; off by - * default. Threaded to both the worker (CFG build) and scope-resolution - * (BasicBlock/CFG emit). - */ - pdg?: boolean; - /** - * Stats inclusion in AGENTS.md and CLAUDE.md. - * - * Commander.js represents `--no-stats` as `stats: boolean` (default - * `true`; `false` when the user passes `--no-stats`), NOT as - * `noStats: boolean`. Reading the negated form would always be - * `undefined` and the flag would silently no-op (#1477). Consumers - * that want "did the user request --no-stats?" should compare with - * `=== false` to distinguish the explicit-off case from the - * default-on case. - */ - stats?: boolean; - /** - * Opt-in auto-commit of any AGENTS.md/CLAUDE.md changes this `analyze` run - * makes. Scoped to only those two files (never `git add -A`); no-ops - * silently if neither exists, neither changed, or the commit step itself - * fails (e.g. no git identity configured). See #2639. - */ - selfCommit?: boolean; - /** Skip installing standard GitNexus skill files directly under .claude/skills/. */ - skipSkills?: boolean; - /** - * Default branch for the generated regression-compare example (#243). From - * `--default-branch`; may also be supplied via `.gitnexusrc`. Resolved to a - * concrete branch (CLI > `.gitnexusrc` > auto-detected origin/HEAD > "main") - * before being threaded into the generated AGENTS.md / CLAUDE.md content. - */ - defaultBranch?: string; - /** - * Index-branch selector (#2106). From `--branch`. Distinct from - * `defaultBranch` (cosmetic base_ref): this routes the index to a per-branch - * slot. NOT sourced from `.gitnexusrc` — the `.gitnexusrc` `branch` key is an - * alias for `defaultBranch` and must not change index placement. Defaults to - * the checked-out branch inside `runFullAnalysis` when omitted. - */ - branch?: string; - /** Pure index mode: skip all file injection (AGENTS.md, CLAUDE.md, skills). */ - indexOnly?: boolean; - /** Index the folder even when no .git directory is present. */ - skipGit?: boolean; - /** - * Override the default basename-derived registry `name` with a - * user-supplied alias (#829). Disambiguates repos whose paths share a - * basename. Persisted — subsequent re-analyses of the same path without - * `--name` preserve the alias. - */ - name?: string; - /** - * Allow registration even when another path already uses the same - * `--name` alias (#829). Intentionally a distinct flag from `--force` - * because the user may want to coexist under the same name WITHOUT - * paying the cost of a pipeline re-index. Maps to registerRepo's - * `allowDuplicateName` option end-to-end. - */ - allowDuplicateName?: boolean; - /** - * Override the walker's large-file skip threshold (#991). Value in KB; - * clamped downstream to the tree-sitter 32 MB ceiling. Sets - * `GITNEXUS_MAX_FILE_SIZE` for the rest of the pipeline. - */ - maxFileSize?: string; - /** Override worker sub-batch idle timeout in seconds. */ - workerTimeout?: string; - /** Control LadybugDB WAL auto-checkpoint threshold during analyze. */ - walCheckpointThreshold?: string; - /** Parse worker pool size (>=1); 0 is rejected (no sequential mode). */ - workers?: string; - embeddingThreads?: string; - embeddingBatchSize?: string; - embeddingSubBatchSize?: string; - embeddingDevice?: string; - /** - * Extra fetch-wrapper function names to treat as HTTP consumers (#1589/#1852 - * residual). Supplied via `.gitnexusrc` `fetchWrappers: [...]`. Threaded into - * the routes phase, where the cross-file consumer scan unions them with the - * auto-detected `fetch()` wrappers so a custom/axios-based wrapper named - * outside the built-in convention still produces `route_map` consumers. - */ - fetchWrappers?: string[]; - /** OpenAI-compatible embeddings base URL (incl. /v1). Overrides GITNEXUS_EMBEDDING_URL. */ - embeddingBaseUrl?: string; - /** Embedding model name. Overrides GITNEXUS_EMBEDDING_MODEL. */ - embeddingModel?: string; - /** Bearer token for the embeddings endpoint. Overrides GITNEXUS_EMBEDDING_API_KEY. Never logged. */ - embeddingAuthToken?: string; - /** Embedding vector dimensions (positive integer string). Overrides GITNEXUS_EMBEDDING_DIMS. */ - embeddingDims?: string; -} +/** + * CLI `analyze` flag shape. Defined in `./analyze-options.js` so + * `analyze-config.ts` can reference it without importing this module back — + * that type import closed a cycle over `analyze` → `analyze-config` and + * `analyze` → `run-analyze` → `analyze-config`. Re-exported here because this + * is where callers have always imported it from. + */ +export type { AnalyzeOptions }; /** * Whether the post-index skill step should run. @@ -846,6 +770,19 @@ export const analyzeCommandWithRunnerIdentity = async ( options?: AnalyzeOptions, ): Promise => analyzeCommand(inputPath, options, runnerIdentityAtBootstrap); +export async function analyzeOrWatchCommandWithRunnerIdentity( + runnerIdentityAtBootstrap: AnalyzerRunnerIdentity, + inputPath?: string, + options: AnalyzeOptions = {}, +): Promise { + if (options.watch) { + const { watchCommandWithRunnerIdentity } = await import('./analyze-watch.js'); + await watchCommandWithRunnerIdentity(runnerIdentityAtBootstrap, inputPath, options); + return; + } + await analyzeCommandWithRunnerIdentity(runnerIdentityAtBootstrap, inputPath, options); +} + const analyzeCommandImpl = async ( inputPath?: string, cliOptions?: AnalyzeOptions, @@ -1437,6 +1374,7 @@ const analyzeCommandImpl = async ( // Extra fetch-wrapper names from `.gitnexusrc` (#1589/#1852 residual); // forwarded to the routes phase consumer scan. fetchWrappers: options.fetchWrappers, + springActuatorPath: options.springActuator, // The CLI always process.exit()s after this returns (success path at the // end of analyzeCommandImpl, error/interrupt paths via process.exit too), // so the finalize close skips the native conn/db close — it can double-free @@ -1501,6 +1439,9 @@ const analyzeCommandImpl = async ( console.error = origError; bar.stop(); console.log(' Already up to date\n'); + if (runOptions.registryName) { + console.log(` Registry name: ${result.repoName}\n`); + } if (baseRefRefreshed.length > 0) { console.log( ` Updated base_ref to "${resolvedDefaultBranch}" in ${baseRefRefreshed.join(', ')}\n`, @@ -1592,6 +1533,7 @@ const analyzeCommandImpl = async ( // exercised on the `--skills` path by analyze-no-stats-bridge.test.ts. noStats: options.stats === false, hasPdg: options.pdg === true, + hasSpringActuator: options.springActuator !== undefined, }, ); } diff --git a/gitnexus/src/cli/auto-sync.ts b/gitnexus/src/cli/auto-sync.ts new file mode 100644 index 000000000..880deb5d5 --- /dev/null +++ b/gitnexus/src/cli/auto-sync.ts @@ -0,0 +1,125 @@ +/** Remote auto-sync CLI (`gitnexus auto-sync`). Local incremental watch lives in `analyze-watch.ts`. */ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { + getAutoSyncConfigPath, + getAutoSyncMutexPath, + readAutoSyncWatchStatus, + resetAutoSyncState, + startAutoSyncWatch, + stopAutoSyncWatch, + type WatchStatusRecord, +} from '../core/auto-sync/index.js'; + +export async function autoSyncCommand(action = 'start'): Promise { + if (action === 'init') { + await initWatchConfig(); + return; + } + if (action === 'reset') { + if (!(await resetAutoSyncState())) { + process.stderr.write( + `[auto-sync] Cannot reset analysis state while the watch mutex is held. Confirm no watch process is running, then remove ${getAutoSyncMutexPath()}.\n`, + ); + process.exitCode = 1; + return; + } + process.stdout.write('[auto-sync] Reset analysis state.\n'); + return; + } + if (action === 'status') { + printStatus(await readAutoSyncWatchStatus()); + return; + } + if (action === 'stop') { + if ((await stopAutoSyncWatch()) !== 'stopped') process.exitCode = 1; + return; + } + if (action === 'restart') { + const result = await stopAutoSyncWatch(); + if (result === 'refused' || result === 'timeout') { + process.exitCode = 1; + return; + } + await startWatchProcess(); + return; + } + if (action !== 'start') { + process.stderr.write(`[auto-sync] Unknown auto-sync action: ${action}\n`); + process.exitCode = 1; + return; + } + await startWatchProcess(); +} + +async function startWatchProcess(): Promise { + const handle = await startAutoSyncWatch(); + if (!handle) { + process.exitCode = 1; + return; + } + + const stop = () => { + void handle.stop().then( + () => { + process.stderr.write('[auto-sync] Watch stopped.\n'); + process.exit(0); + }, + (error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`[auto-sync] Failed to stop watch: ${message}\n`); + process.exit(1); + }, + ); + }; + process.once('SIGINT', stop); + process.once('SIGTERM', stop); +} + +function printStatus(status: WatchStatusRecord): void { + const parts = [`state=${status.state}`]; + if (status.pid) parts.push(`pid=${status.pid}`); + if (status.configPath) parts.push(`config=${status.configPath}`); + if (status.message) parts.push(`message=${status.message}`); + parts.push(`updated_at=${status.updatedAt}`); + process.stdout.write(`${parts.join(' ')}\n`); +} + +async function initWatchConfig(): Promise { + const configPath = getAutoSyncConfigPath(); + try { + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile( + configPath, + defaultSyncConfig(path.resolve(path.dirname(configPath), 'repos')), + { + flag: 'wx', + }, + ); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === 'EEXIST') { + process.stderr.write(`[auto-sync] Config already exists: ${configPath}\n`); + process.exitCode = 1; + return; + } + throw err; + } + process.stdout.write(`[auto-sync] Created ${configPath}\n`); +} + +function defaultSyncConfig(localPath: string): string { + return [ + 'sync_interval_minutes: 10', + 'max_concurrency: 1', + 'repo_git_timeout: 10s', + 'analyze_timeout: 5m', + 'analyze_failure_threshold: 3', + 'projects:', + ` - local_path: ${localPath}`, + ' branches: [master, main]', + ' overwrite_local_changes: false', + ' remote_urls:', + ' - git@github.com:owner/repo.git', + '', + ].join('\n'); +} diff --git a/gitnexus/src/cli/detect-changes-format.ts b/gitnexus/src/cli/detect-changes-format.ts index 7077334ef..9e91cc330 100644 --- a/gitnexus/src/cli/detect-changes-format.ts +++ b/gitnexus/src/cli/detect-changes-format.ts @@ -1,10 +1,12 @@ import { t } from './i18n/index.js'; +import { formatSymbolLine } from './format-symbol.js'; type DetectChangesSummary = { changed_files?: number; changed_count?: number; affected_count?: number; risk_level?: string; + message?: string; }; type ChangedSymbol = { @@ -25,6 +27,8 @@ type AffectedProcess = { type DetectChangesResult = { error?: unknown; + partial?: boolean; + truncated?: boolean; summary?: DetectChangesSummary; changed_symbols?: ChangedSymbol[]; affected_processes?: AffectedProcess[]; @@ -35,11 +39,51 @@ export function formatDetectChangesResult(result: unknown): string { if (payload.error) return t('common.error', { message: String(payload.error) }); const summary = payload.summary ?? {}; + // A swallowed query failure sets `partial` and leaves the counts at zero + // (#2283). Printing only "No changes detected." turns a degraded run into a + // clean bill of health for the pre-commit gate, so say so either way. + // `truncated` is its sibling flag: the backend caps the changed_symbols + // LISTING (never the counts), so a short list is not proof of a short diff. + // Both lead the output — a caveat printed after the summary is read too late. + const notes: string[] = []; + if (payload.partial) notes.push(t('tool.detectChanges.partial')); + // The plain truncation note reassures that the counts are whole. That is only + // true when the run did NOT also degrade — `changed_count` sums the batches + // that succeeded — so the two flags together get a different sentence. + if (payload.truncated) + notes.push( + t(payload.partial ? 'tool.detectChanges.truncatedDegraded' : 'tool.detectChanges.truncated'), + ); + if ((summary.changed_count ?? 0) === 0) { - return t('tool.detectChanges.noChanges'); + // Parse-fail payloads set `partial` and an honest `message` (#2915/#3131). + // Production *clean* trees also set English `message: 'No changes detected.'` + // — that must go through `t('tool.detectChanges.noChanges')` or zh-CN never + // fires. Only pass the backend string through on a degraded/parse-fail run. + if ( + payload.partial && + typeof summary.message === 'string' && + summary.message.trim().length > 0 + ) { + return [...notes, summary.message.trim()].join('\n'); + } + // Confirmed no-overlap: files parsed, mapping succeeded, zero symbols. + // `queryDegraded` is `partial: true` with the same counts and no message — + // do not call that a confirmed mapping (#3131 honesty). + if (!payload.partial && (summary.changed_files ?? 0) > 0) { + return [ + ...notes, + t('tool.detectChanges.noOverlappingSymbols', { files: summary.changed_files }), + ].join('\n'); + } + if (payload.partial) { + return notes.join('\n'); + } + return [...notes, t('tool.detectChanges.noChanges')].join('\n'); } const lines: string[] = []; + if (notes.length > 0) lines.push(...notes, ''); lines.push( t('tool.detectChanges.changesSummary', { files: summary.changed_files ?? 0, @@ -59,7 +103,7 @@ export function formatDetectChangesResult(result: unknown): string { lines.push(t('tool.detectChanges.changedSymbols')); const shown = changed.slice(0, 15); for (const symbol of shown) { - lines.push(` ${symbol.type ?? 'Symbol'} ${symbol.name ?? '?'} → ${symbol.filePath ?? '?'}`); + lines.push(formatSymbolLine(symbol.type, symbol.name, symbol.filePath)); } // Overflow is measured against the TRUE total (summary.changed_count), not // the array length — the array may already be `--limit`-sliced, so using its diff --git a/gitnexus/src/cli/eval-server.ts b/gitnexus/src/cli/eval-server.ts index 31289efb2..b2f656c39 100644 --- a/gitnexus/src/cli/eval-server.ts +++ b/gitnexus/src/cli/eval-server.ts @@ -45,6 +45,7 @@ import { import { logger } from '../core/logger.js'; import { cliInfo, cliWarn, cliError } from './cli-message.js'; import { formatDetectChangesResult } from './detect-changes-format.js'; +import { formatSymbolLine } from './format-symbol.js'; export { formatDetectChangesResult } from './detect-changes-format.js'; @@ -209,7 +210,7 @@ export function formatQueryResult(result: any): string { if (defs.length > 0) { lines.push(`Standalone definitions:`); for (const d of defs.slice(0, 8)) { - lines.push(` ${d.type || 'Symbol'} ${d.name} → ${d.filePath || '?'}`); + lines.push(formatSymbolLine(d.type, d.name, d.filePath)); } if (defs.length > 8) lines.push(` ... and ${defs.length - 8} more`); } @@ -301,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}` : ''; @@ -566,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[] = []; @@ -593,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 = { diff --git a/gitnexus/src/cli/format-symbol.ts b/gitnexus/src/cli/format-symbol.ts new file mode 100644 index 000000000..20a0fc52a --- /dev/null +++ b/gitnexus/src/cli/format-symbol.ts @@ -0,0 +1,22 @@ +/** + * Symbol listing line — the one rendering of `Type name → path` shared by every + * formatter that lists symbols. Kept in its own tool-neutral module so a new + * consumer does not have to import it from another tool's formatter. + */ + +/** + * One indented `Type name → path` listing line for a symbol. Shared by the + * `detect_changes` CLI formatter and the eval-server `query` formatter so the + * two renderings cannot drift apart. + * + * `||`, not `??`: a node whose label came back as an EMPTY STRING (several node + * types do — see enrichCandidateLabels) still needs the placeholder, and `??` + * would print the empty string instead. + */ +export function formatSymbolLine( + type: string | undefined, + name: string | undefined, + filePath: string | undefined, +): string { + return ` ${type || 'Symbol'} ${name || '?'} → ${filePath || '?'}`; +} diff --git a/gitnexus/src/cli/generated-skill.ts b/gitnexus/src/cli/generated-skill.ts new file mode 100644 index 000000000..ab0377f3e --- /dev/null +++ b/gitnexus/src/cli/generated-skill.ts @@ -0,0 +1,17 @@ +/** + * Metadata for one repo-specific skill file generated from a detected + * community. + * + * Produced by `skill-gen`'s `generateSkillFiles` and consumed by `ai-context` + * when it lists the generated skills in AGENTS.md / CLAUDE.md. It lives in this + * leaf module rather than in either of those so the consumer does not have to + * import the producer for a type — `ai-context` already supplies the + * `.agents/` mirror check that `skill-gen` calls, and the two directions + * together made an import cycle. + */ +export interface GeneratedSkillInfo { + name: string; + label: string; + symbolCount: number; + fileCount: number; +} diff --git a/gitnexus/src/cli/group.ts b/gitnexus/src/cli/group.ts index 3111f69d0..abc13fa2b 100644 --- a/gitnexus/src/cli/group.ts +++ b/gitnexus/src/cli/group.ts @@ -1,6 +1,8 @@ // gitnexus/src/cli/group.ts import { createRequire } from 'node:module'; import type { Command } from 'commander'; +import type { RegistryWriteOutcome } from '../core/group/sync.js'; +import type { MatchType } from '../core/group/types.js'; import { logger } from '../core/logger.js'; const _require = createRequire(import.meta.url); @@ -120,16 +122,43 @@ export function registerGroupCommands(program: Command): void { indexStale: boolean; contractsStale: boolean; missing: boolean; + /** + * Optional here on purpose: a payload produced before the split + * carries no such key, and an absent one must degrade to the + * label this command has always printed rather than to the new + * one — an unrecorded cause is not evidence of a cause. + */ + unresolvable?: boolean; + unresolvableReason?: string; commitsBehind?: number; } >; missingRepos?: string[]; + unreadableRepos?: string[]; + suppressedMatchStages?: string[]; }; console.log(' Repo index / contracts staleness:'); for (const [repoPath, row] of Object.entries(st.repos || {})) { if (row.missing) { - console.log(` ${repoPath.padEnd(25)} MISSING (not in registry or unreadable)`); + // Two different facts with two different remedies: a repo the + // registry never heard of is fixed by indexing it, while an entry + // the resolver choked on is fixed by repairing the registry. + // Printing "no entry in the registry" for the second one states a + // cause that was never measured, and points at the wrong repair. + if (row.unresolvable) { + // The reason can be multi-line — an ambiguous registry names + // every colliding clone. Fold it onto this row's line rather + // than truncating it: those paths are what the operator acts on, + // and a table row that swallows half its own explanation is the + // failure this label exists to stop. + const why = (row.unresolvableReason ?? 'the registry entry could not be resolved') + .replace(/\s+/g, ' ') + .trim(); + console.log(` ${repoPath.padEnd(25)} UNRESOLVABLE (${why})`); + continue; + } + console.log(` ${repoPath.padEnd(25)} MISSING (no entry in the registry)`); continue; } const idx = row.indexStale @@ -138,9 +167,41 @@ export function registerGroupCommands(program: Command): void { const ctr = row.contractsStale ? ' CONTRACTS_STALE' : ''; console.log(` ${repoPath.padEnd(25)} ${idx}${ctr}`); } + // `undefined` and `[]` are different answers here: a registry written + // before this was tracked has no opinion, while an empty array is a + // measurement. Printing nothing for both would let an unmeasured sync + // read as evidence that every index opened cleanly. + // + // `undefined` covers two ways of not knowing — the field is absent, or + // it held something that was not a list of repo paths and `getStatus` + // declined to guess. Naming only the first would make a corrupt + // registry read as a merely old one, which is the same shape of wrong + // answer this command exists to stop giving. + const unreadable = st.unreadableRepos; + if (unreadable === undefined) { + console.log( + `\n Last sync unreadable repos: not recorded` + + `\n (the registry predates this field, or its value could not be read)` + + `\n Re-run \`gitnexus group sync\` to record it.`, + ); + } else if (unreadable.length > 0) { + console.log(`\n Last sync unreadable repos: ${unreadable.join(', ')}`); + } if ((st.missingRepos || []).length > 0) { console.log(`\n Last sync missing repos: ${st.missingRepos!.join(', ')}`); } + // Only the populated case prints. Absent means a registry that predates + // the field, and empty is the ordinary clean sync — neither is worth a + // line, whereas a narrowed registry changes how every later answer + // should be read. + const skippedStages = st.suppressedMatchStages ?? []; + if (skippedStages.length > 0) { + console.log( + `\n Last sync skipped matching stages: ${skippedStages.join(', ')}` + + `\n Cross-links those stages would have found are absent by request.` + + `\n Re-run \`gitnexus group sync\` without --exact-only for the complete set.`, + ); + } } finally { await backend.dispose().catch(() => {}); } @@ -149,39 +210,137 @@ export function registerGroupCommands(program: Command): void { group .command('sync ') .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) => { const { getGroupDir, getDefaultGitnexusDir } = await import('../core/group/storage.js'); const { loadGroupConfig } = await import('../core/group/config-parser.js'); - const { syncGroup } = await import('../core/group/sync.js'); + const { syncGroup, formatGroupSyncAmbiguousError } = await import('../core/group/sync.js'); + const { GroupSyncLockError } = await import('../core/group/group-lock.js'); + const { RegistryAmbiguousTargetError } = await import('../storage/repo-manager.js'); const groupDir = getGroupDir(getDefaultGitnexusDir(), name); const config = await loadGroupConfig(groupDir); console.log(`Syncing group "${name}" (${Object.keys(config.repos).length} repos)...\n`); - const result = await syncGroup(config, { - groupDir, - allowStale: Boolean(opts.allowStale), - verbose: Boolean(opts.verbose), - skipEmbeddings: Boolean(opts.skipEmbeddings), - exactOnly: Boolean(opts.exactOnly), - }); + let result: Awaited>; + try { + result = await syncGroup(config, { + groupDir, + verbose: Boolean(opts.verbose), + exactOnly: Boolean(opts.exactOnly), + }); + } catch (err) { + if (err instanceof RegistryAmbiguousTargetError) { + logger.error(`⚠️ Did not sync group "${name}": ${formatGroupSyncAmbiguousError(err)}`); + process.exitCode = 1; + return; + } + // A sync that could not take the group's lock did NOT run and wrote + // nothing (R9 fails closed). That is an operator-actionable outcome, not + // a crash, so report it as a failed command rather than letting it + // surface as an unhandled rejection with a stack trace — commander's + // async actions have no error handler, so an uncaught throw here would + // print exactly that. + if (!(err instanceof GroupSyncLockError)) throw err; + logger.error(`⚠️ Did not sync group "${name}": ${err.message}`); + process.exitCode = 1; + return; + } if (opts.json) { console.log(JSON.stringify(result, null, 2)); } else { - console.log(`\nMatching cascade:`); - const exactLinks = result.crossLinks.filter((l) => l.matchType === 'exact'); - console.log(` exact: ${exactLinks.length} cross-links (confidence 1.0)`); - console.log(` unmatched: ${result.unmatched.length} contracts`); - console.log( - `\nWrote contracts.json (${result.contracts.length} contracts, ${result.crossLinks.length} cross-links)`, - ); + // Repos we could not read are the most likely explanation for a small + // or empty contract count, so they are reported before the counts — + // otherwise a run that read nothing looks exactly like a clean run. + if (result.unreadableRepos.length > 0) { + // No "re-run with GITNEXUS_LOG_LEVEL=warn" hint: the default level is + // `info`, and pino emits `warn` (40) at `info` (30), so the reason was + // already printed by this same run — raising the level to `warn` would + // only suppress the surrounding `info` output. + console.log( + `\n ⚠️ Could not extract contracts from: ${result.unreadableRepos.join(', ')}` + + `\n None of their contracts are included in this sync (the warning above says why),` + + `\n or check \`gitnexus doctor\` in the affected repo.`, + ); + } + if (result.missingRepos.length > 0) { + console.log( + `\n ⚠️ Not found in the registry: ${result.missingRepos.join(', ')}` + + `\n Index them with \`gitnexus analyze\`, or remove them from group.yaml.`, + ); + } + // Every stage that produced a link, not just `exact`. This used to print + // `Matching cascade:` and then count `exact` alone, while the `Wrote + // contracts.json (…)` line below reports `result.crossLinks.length` — + // which also includes `manifest` and `wildcard` links. For any group with + // those, the two numbers disagreed with nothing on screen explaining why. + // Summing the stages here makes them reconcile by construction. + console.log(`\nMatching:`); + // Exhaustive by construction, same idiom as OUTCOME_LINE below: adding a + // MatchType fails the build here instead of silently going uncounted and + // reopening the very mismatch this replaced. Every stage prints even at + // zero — a stage that is absent reads as "did not apply", not "found none". + const STAGE_COUNTS: Record = { + exact: 0, + manifest: 0, + wildcard: 0, + }; + for (const link of result.crossLinks) STAGE_COUNTS[link.matchType] += 1; + // A stage the sync was told to skip is reported as skipped, not as a + // zero count. The two are different facts — "ran, matched nothing" and + // "never ran" — and printing both as `0` is the same conflation this + // block replaced. Driven by what the sync did (`suppressedMatchStages`) + // rather than by what the caller asked for, so it stays correct on the + // outcomes where the run ended without writing a registry. + for (const stage of Object.keys(STAGE_COUNTS) as MatchType[]) { + const count = STAGE_COUNTS[stage]; + const label = `${stage}:`.padEnd(10); + if (result.suppressedMatchStages.includes(stage)) { + console.log(` ${label} skipped (--exact-only)`); + continue; + } + const confidence = stage === 'exact' ? ' (confidence 1.0)' : ''; + console.log(` ${label} ${count} cross-links${confidence}`); + } + console.log(` ${'unmatched:'.padEnd(10)} ${result.unmatched.length} contracts`); + // Driven by what actually happened to the file. This line used to be + // unconditional, so a run that deliberately preserved the previous + // registry still announced `Wrote contracts.json (0 contracts, 0 + // cross-links)` — a confident false statement about persisted state, on + // the exact path this command exists to make legible. + // Exhaustive by construction: a `Record` keyed on the union means a + // new outcome fails the build here instead of printing nothing, which + // is what previously pushed a distinct state into `preserved` and made + // this summary false on one of the two branches it then covered. + const OUTCOME_LINE: Record = { + written: + `\nWrote contracts.json (${result.contracts.length} contracts, ` + + `${result.crossLinks.length} cross-links)`, + preserved: + `\nKept the previous contracts.json — no repo in this group could be read.` + + `\n Its contracts and cross-links are unchanged; only the unreadable/missing` + + `\n repo lists were refreshed to describe THIS run. Fix the repos above and re-run.`, + superseded: + `\nDid NOT touch contracts.json — no repo in this group could be read, and another` + + `\n sync replaced the file while this one waited for the group lock. That sync's` + + `\n result stands and this run's repo lists were NOT recorded: they describe a` + + `\n group state older than what is on disk. Fix the repos above and re-run.`, + 'no-prior-registry': + `\nDid NOT write contracts.json — no repo in this group could be read,` + + `\n and there is no previous contracts.json to fall back on. Fix the repos` + + `\n above and re-run.`, + // Nothing to say: the caller asked for no write. + 'not-attempted': null, + }; + const line = OUTCOME_LINE[result.registryOutcome]; + if (line) console.log(line); } }); @@ -281,11 +440,28 @@ export function registerGroupCommands(program: Command): void { // repos — reporting it as crossings understates a fan-out cap the // same way #2787's totals did. const dropped = (raw as { truncatedRepos?: string[] })?.truncatedRepos ?? []; - console.log( - dropped.length > 0 - ? ` risk is a LOWER BOUND — fan-out stopped early; crossings to ${dropped.length} repo(s) not traversed: ${dropped.join(', ')}` - : ' risk is a LOWER BOUND — the local impact walk did not complete (every bridge crossing was traversed)', - ); + const reason = (raw as { truncationReason?: string })?.truncationReason; + // Keyed on the REASON, not on which incidental fact happens to be + // non-empty. `truncatedRepos` is populated for a structural gap too + // — the bridge's incomplete repos are unioned into it even when ZERO + // crossings were attempted — so branching on its length first + // reported "fan-out stopped early" for a run where nothing stopped + // early, and omitted the only remedy that works. Same false-cause + // shape the contract listing was just re-gated for, one command over. + const floorReason = (): string => { + if (reason === 'suppressed-stage') { + return 'the last sync skipped a matching stage (--exact-only); re-run `gitnexus group sync` without it for the complete graph'; + } + if (reason === 'incomplete-sync') { + return dropped.length > 0 + ? `the last sync could not account for ${dropped.join(', ')}; their contracts are absent from every query against this bridge — re-run \`gitnexus group sync\`` + : 'the last sync could not say which repos it read — re-run `gitnexus group sync`'; + } + return dropped.length > 0 + ? `fan-out stopped early; crossings to ${dropped.length} repo(s) not traversed: ${dropped.join(', ')}` + : 'the local impact walk did not complete (every bridge crossing was traversed)'; + }; + console.log(` risk is a LOWER BOUND — ${floorReason()}`); } } } finally { @@ -370,7 +546,15 @@ export function registerGroupCommands(program: Command): void { return; } - const { contracts, crossLinks } = raw as { + const { + contracts, + crossLinks, + truncated, + unreadableRepos, + missingRepos, + suppressedMatchStages, + truncationReason, + } = raw as { contracts: Array<{ role: string; contractId: string; @@ -384,10 +568,21 @@ export function registerGroupCommands(program: Command): void { confidence: number; contractId: string; }>; + truncated?: boolean; + suppressedMatchStages?: string[]; + truncationReason?: string; + unreadableRepos?: string[]; + missingRepos?: string[]; }; if (opts.json) { - console.log(JSON.stringify({ contracts, crossLinks }, null, 2)); + // The whole payload, not a re-serialized subset. Destructuring the two + // fields this command happens to print and rebuilding an object from + // them dropped everything else the service returned — which is how the + // completeness fields were invisible here while the MCP tool carried + // them. Printing `raw` means a field added to the service reaches + // `--json` without a matching edit in this file. + console.log(JSON.stringify(raw, null, 2)); } else { console.log(`Contracts (${contracts.length}):`); for (const c of contracts) { @@ -399,6 +594,39 @@ export function registerGroupCommands(program: Command): void { ` ${l.from.repo} -> ${l.to.repo} [${l.matchType}, conf=${l.confidence}] ${l.contractId}`, ); } + // Separate from `truncated` below, and deliberately so: that one means + // the sync could not read something and the remedy is to fix the repo. + // This one means the sync was ASKED to skip a stage, and the remedy is + // to re-run without the flag. A listing narrowed on purpose is still + // narrowed, and without this the human view showed nothing at all. + if (suppressedMatchStages && suppressedMatchStages.length > 0) { + console.log( + `\n⚠️ This listing is a lower bound: the last sync skipped ${suppressedMatchStages.join(', ')} matching` + + `\n (--exact-only), so cross-links that stage would have found are absent.` + + `\n Re-run \`gitnexus group sync\` without --exact-only for the complete set.`, + ); + } + // Gated on the REASON, not just the flag. A suppressed stage sets + // `truncated` with both repo lists empty, which sent this block down + // its else-branch and printed "the last sync did not record which + // repos it could read" — a false statement, with the wrong remedy, + // about a sync that recorded them fine. The suppressed-stage warning + // above already said the true thing. When a repo gap co-occurs the + // reason is 'incomplete-sync' (the repo side takes precedence in + // `crossRepoCompleteness`), so this block still runs for it. + if (truncated && truncationReason !== 'suppressed-stage') { + // Counts above are a floor, not a census. Name the repos when the + // registry recorded them, and say so plainly when it did not — a + // listing that cannot say what it is missing is still incomplete. + const absent = [...(unreadableRepos ?? []), ...(missingRepos ?? [])]; + console.log( + absent.length > 0 + ? `\n⚠️ This listing is incomplete: the last sync could not account for ${absent.join(', ')}.` + + `\n Contracts from those repos are absent, so the counts above are a lower bound.` + : `\n⚠️ This listing is incomplete: the last sync did not record which repos it could` + + `\n read, so the counts above are a lower bound. Re-run group sync.`, + ); + } } } finally { await backend.dispose().catch(() => {}); diff --git a/gitnexus/src/cli/help-i18n.ts b/gitnexus/src/cli/help-i18n.ts index 58f28d11a..283e99832 100644 --- a/gitnexus/src/cli/help-i18n.ts +++ b/gitnexus/src/cli/help-i18n.ts @@ -13,6 +13,8 @@ const COMMAND_DESCRIPTION_KEYS = { '': 'help.description.root', setup: 'help.command.setup.description', uninstall: 'help.command.uninstall.description', + watch: 'help.command.watch.description', + 'auto-sync': 'help.command.autoSync.description', analyze: 'help.command.analyze.description', index: 'help.command.index.description', serve: 'help.command.serve.description', @@ -72,6 +74,8 @@ const OPTION_DESCRIPTION_KEYS = { 'analyze|--embedding-batch-size ': 'help.option.analyze.embeddingBatchSize', 'analyze|--embedding-sub-batch-size ': 'help.option.analyze.embeddingSubBatchSize', 'analyze|--embedding-device ': 'help.option.analyze.embeddingDevice', + 'analyze|--watch': 'help.option.analyze.watch', + 'analyze|--debounce ': 'help.option.analyze.debounce', 'index|-f, --force': 'help.option.index.force', 'index|--allow-non-git': 'help.option.index.allowNonGit', 'mcp|--http': 'help.option.mcp.http', @@ -155,9 +159,7 @@ const OPTION_DESCRIPTION_KEYS = { 'embeddings install|--cuda': 'help.option.embeddings.install.cuda', 'embeddings install|--force': 'help.option.embeddings.install.force', 'group create|--force': 'help.option.group.create.force', - 'group sync|--skip-embeddings': 'help.option.group.sync.skipEmbeddings', 'group sync|--exact-only': 'help.option.group.sync.exactOnly', - 'group sync|--allow-stale': 'help.option.group.sync.allowStale', 'group sync|--verbose': 'help.option.group.sync.verbose', 'group sync|--json': 'help.option.json', 'group impact|--target ': 'help.option.group.impact.target', diff --git a/gitnexus/src/cli/i18n/en.ts b/gitnexus/src/cli/i18n/en.ts index 566dc6d87..58904c48f 100644 --- a/gitnexus/src/cli/i18n/en.ts +++ b/gitnexus/src/cli/i18n/en.ts @@ -33,6 +33,17 @@ export const en = { 'status.workspaceIndexLabel': "Workspace index: last analyzed on '{{primary}}' (re-run gitnexus analyze to follow the current branch)", 'status.status': 'Status', + 'status.indexContentCurrent': 'Index content: matches all {{count}} covered file(s)', + 'status.indexContentDrifted': + 'Index content: {{changed}} changed, {{added}} added, {{deleted}} deleted', + 'status.indexContentMore': ' ...and {{count}} more {{label}}', + 'status.indexContentUnmeasurable': + 'Index content: not comparable ({{reason}}); fell back to the working-tree check', + 'status.indexContentScanFailed': + 'Index content: coverage scan failed; treating the index as stale', + 'status.driftChanged': 'changed', + 'status.driftAdded': 'added', + 'status.driftDeleted': 'deleted', 'status.upToDate': '✅ up-to-date', 'status.stale': '⚠️ stale (re-run gitnexus analyze)', 'clean.deleteAll': 'This will delete GitNexus indexes for {{count}} repo(s):', @@ -65,6 +76,16 @@ export const en = { 'tool.warn.unknownKind': "--kind '{{kind}}' is not a known symbol kind (e.g. Function, Class, Method); it will not narrow the result.", 'tool.detectChanges.noChanges': 'No changes detected.', + 'tool.detectChanges.noOverlappingSymbols': + 'Diff touched {{files}} file(s) but no indexed symbols overlap those hunks — not a clean tree.', + 'tool.detectChanges.partial': + 'PARTIAL RESULT: a graph query failed, so changed symbols may be missing. Do not read this as a clean pre-commit check.', + 'tool.detectChanges.truncated': + 'LISTING CAPPED: the changed-symbol list was capped, so it does not name every changed symbol. The counts and risk level still cover all of them.', + // The reassurance above is only true on its own. When the run also degraded, + // `changed_count` was summed from the batches that SUCCEEDED, so it is a floor. + 'tool.detectChanges.truncatedDegraded': + 'LISTING CAPPED: the changed-symbol list was capped. The run also degraded, so the counts are a lower bound, not a total.', 'tool.detectChanges.changesSummary': 'Changes: {{files}} files, {{symbols}} symbols', 'tool.detectChanges.affectedProcesses': 'Affected processes: {{count}}', 'tool.detectChanges.riskLevel': 'Risk level: {{risk}}', @@ -124,6 +145,16 @@ export const en = { 'One-time setup: configure MCP for Cursor, Claude Code, Antigravity, OpenCode, CodeBuddy, Qoder, Codex', 'help.command.uninstall.description': 'Reverse `setup`: remove GitNexus MCP entries, skills, and hooks from all detected editors', + 'help.command.autoSync.description': + 'Control scheduled repository clone/pull and analysis from GITNEXUS_HOME/watch_config.yml', + 'help.autoSync.details': + '\nActions: init, start (default), restart, stop, status, reset\nConfiguration: GITNEXUS_HOME/watch_config.yml\nRuntime files: GITNEXUS_HOME/watch/watch.pid, watch.mutex, watch.owner.json, watch.status.json, auto-sync-state.json\nRecovery: mutexes with verified dead owners are reclaimed automatically; invalid or legacy mutexes fail closed and require manual removal after confirming no watch process is running.\nWrites: GITNEXUS_HOME/watch/project_commit_info.txt\nRemote URLs: only SSH URLs on github.com, gitlab.com, and gitee.com are allowed.\nRuns once immediately, then repeats on sync_interval_minutes.', + 'help.command.watch.description': + 'Ambiguous: use `analyze --watch` for local files, or `auto-sync` for scheduled remotes', + 'help.watch.details': + '\n`gitnexus watch` does not start a watcher.\n Local working-tree incremental index: gitnexus analyze --watch\n Scheduled remote clone/pull + analyze: gitnexus auto-sync start\n', + 'error.watch.ambiguous': + '`gitnexus watch` is ambiguous.\n Local working-tree incremental index: gitnexus analyze --watch\n Scheduled remote clone/pull + analyze: gitnexus auto-sync start\n', 'help.command.analyze.description': 'Index a repository (full analysis)', 'help.command.index.description': 'Register an existing .gitnexus/ folder into the global registry (no re-analysis needed)', @@ -182,7 +213,7 @@ export const en = { 'help.option.analyze.skills': 'Generate repo-specific skill files from detected communities (no-op when --index-only is also set).', 'help.option.analyze.skipAgentsMd': - 'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md', + 'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md. Does not skip standard skills in .claude/skills or .agents/skills; use --skip-skills for those. Community skills from --skills are unaffected.', 'help.option.analyze.noStats': 'Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md', 'help.option.analyze.selfCommit': 'Auto-commit AGENTS.md/CLAUDE.md changes after analyze (opt-in, off by default). Scoped to only those two files (never `git add -A`); no-ops if neither exists, neither changed, or the repo has no git identity configured.', @@ -209,6 +240,8 @@ export const en = { 'help.option.analyze.embeddingBatchSize': 'Number of nodes per embedding batch', 'help.option.analyze.embeddingSubBatchSize': 'Number of chunks per embedding model call', 'help.option.analyze.embeddingDevice': 'Embedding device: auto, cpu, dml, cuda, or wasm', + 'help.option.analyze.watch': 'Keep the index current with serialized incremental refreshes', + 'help.option.analyze.debounce': 'Watch quiet period before refreshing (milliseconds)', 'help.option.index.force': 'Register even if index metadata is missing (stats will be empty)', 'help.option.index.allowNonGit': 'Allow registering folders that are not Git repositories', 'help.option.port': 'Port number', @@ -217,7 +250,7 @@ export const en = { 'help.option.mcp.host': 'HTTP bind address (only with --http). Default: 127.0.0.1 (loopback). Use 0.0.0.0 to expose to all interfaces.', 'help.option.mcp.authToken': - 'Require this bearer token in the Authorization header (only with --http); may also be set via the GITNEXUS_MCP_AUTH_TOKEN env var. Required for a non-loopback bind (--host 0.0.0.0/::), which otherwise refuses to start.', + "Require this bearer token in the Authorization header (only with --http); may also be set via the GITNEXUS_MCP_AUTH_TOKEN env var, which also enables MCP Bearer auth on gitnexus serve's /api/mcp route. Required for a non-loopback bind (--host 0.0.0.0/::), which otherwise refuses to start.", 'help.option.force.confirmation': 'Skip confirmation prompt', 'help.option.uninstall.force': 'Apply the changes (default is a dry-run preview)', 'help.option.clean.all': 'Clean all indexed repos', @@ -226,16 +259,15 @@ 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: openai, openrouter, azure, custom, cursor, claude, codex, or opencode (default: openai)', - 'help.option.wiki.model': 'LLM model or Azure deployment name (default: minimax/minimax-m2.5)', + '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', 'help.option.wiki.apiKey': 'LLM API key or Azure api-key (saved to ~/.gitnexus/config.json)', 'help.option.wiki.apiVersion': 'Azure api-version query param, e.g. 2024-10-21 (legacy Azure API only)', - 'help.option.wiki.reasoningModel': - 'Mark deployment as reasoning model (o1/o3/o4-mini) — strips temperature, uses max_completion_tokens', - 'help.option.wiki.noReasoningModel': 'Disable reasoning model mode (overrides saved config)', + 'help.option.wiki.reasoningModel': 'Enable reasoning mode; MiniMax-M3 uses adaptive thinking', + 'help.option.wiki.noReasoningModel': 'Disable reasoning mode; MiniMax-M3 disables thinking', 'help.option.wiki.concurrency': 'Parallel LLM calls (default: 3)', 'help.option.wiki.timeout': 'LLM request timeout in seconds (default: disabled)', 'help.option.wiki.retries': 'Max LLM retry attempts per request (default: 3)', @@ -286,10 +318,9 @@ export const en = { 'help.option.embeddings.install.force': 'Install into the runtime prefix even when the stack already resolves', 'help.option.group.create.force': 'Overwrite existing group', - 'help.option.group.sync.skipEmbeddings': 'Exact + BM25 only (no embedding fallback)', - 'help.option.group.sync.exactOnly': 'Exact match only', - 'help.option.group.sync.allowStale': 'Skip stale index warnings', - 'help.option.group.sync.verbose': 'Show each cross-link detail', + 'help.option.group.sync.exactOnly': + 'Skip wildcard service matching; cross-link on exact contract-id match only (manifest links still apply)', + 'help.option.group.sync.verbose': 'Show additional sync diagnostics', 'help.option.status.json': 'Emit machine-readable index and analyzer provenance', 'help.option.json': 'JSON output', 'help.option.group.impact.target': 'Symbol or file name to analyze', diff --git a/gitnexus/src/cli/i18n/zh-CN.ts b/gitnexus/src/cli/i18n/zh-CN.ts index 0c99d37d9..de9249cc4 100644 --- a/gitnexus/src/cli/i18n/zh-CN.ts +++ b/gitnexus/src/cli/i18n/zh-CN.ts @@ -37,6 +37,15 @@ export const zhCN = { 'status.workspaceIndexLabel': "工作区索引:最近在 '{{primary}}' 分支上分析(重新运行 gitnexus analyze 以跟随当前分支)", 'status.status': '状态', + 'status.indexContentCurrent': '索引内容:与覆盖的全部 {{count}} 个文件一致', + 'status.indexContentDrifted': + '索引内容:{{changed}} 个已修改,{{added}} 个新增,{{deleted}} 个已删除', + 'status.indexContentMore': ' ……另有 {{count}} 个 {{label}}', + 'status.indexContentUnmeasurable': '索引内容:无法比对({{reason}}),已回退到工作区检查', + 'status.indexContentScanFailed': '索引内容:覆盖扫描失败,按过期处理', + 'status.driftChanged': '已修改', + 'status.driftAdded': '新增', + 'status.driftDeleted': '已删除', 'status.upToDate': '✅ 已是最新', 'status.stale': '⚠️ 已过期(重新运行 gitnexus analyze)', 'clean.deleteAll': '将删除 {{count}} 个仓库的 GitNexus 索引:', @@ -69,6 +78,14 @@ export const zhCN = { 'tool.warn.unknownKind': "--kind '{{kind}}' 不是已知的符号类型(如 Function、Class、Method),不会用于缩小结果范围。", 'tool.detectChanges.noChanges': '未检测到变更。', + 'tool.detectChanges.noOverlappingSymbols': + 'diff 触及 {{files}} 个文件,但没有索引符号与这些 hunk 重叠 — 并非干净工作区。', + 'tool.detectChanges.partial': + '结果不完整:图查询失败,可能遗漏已变更符号。请勿将其视为通过的提交前检查。', + 'tool.detectChanges.truncated': + '列表已截断:已变更符号列表被截断,未列出全部变更符号。计数与风险等级仍涵盖全部符号。', + 'tool.detectChanges.truncatedDegraded': + '列表已截断:已变更符号列表被截断。本次运行同时不完整,因此计数为下限而非总数。', 'tool.detectChanges.changesSummary': '变更:{{files}} 个文件,{{symbols}} 个符号', 'tool.detectChanges.affectedProcesses': '受影响流程:{{count}}', 'tool.detectChanges.riskLevel': '风险等级:{{risk}}', @@ -127,6 +144,16 @@ export const zhCN = { '一次性设置:为 Cursor、Claude Code、Antigravity、OpenCode、CodeBuddy、Qoder、Codex 配置 MCP', 'help.command.uninstall.description': '撤销 `setup`:从所有检测到的编辑器中移除 GitNexus 的 MCP 配置、技能和钩子', + 'help.command.autoSync.description': + '控制基于 GITNEXUS_HOME/watch_config.yml 的定时 clone/pull 和分析', + 'help.autoSync.details': + '\n操作:init、start(默认)、restart、stop、status、reset\n配置:GITNEXUS_HOME/watch_config.yml\n运行时文件:GITNEXUS_HOME/watch/watch.pid、watch.mutex、watch.owner.json、watch.status.json、auto-sync-state.json\n恢复:已验证 owner 退出的 mutex 会自动回收;无效或旧版 mutex 会安全拒绝,确认没有 watch 进程运行后再手动删除。\n写入:GITNEXUS_HOME/watch/project_commit_info.txt\n远程地址:仅允许 github.com、gitlab.com 和 gitee.com 上的 SSH 地址。\n启动后立即运行一次,之后按 sync_interval_minutes 重复。', + 'help.command.watch.description': + '含义不明确:本地文件请用 `analyze --watch`,定时远程同步请用 `auto-sync`', + 'help.watch.details': + '\n`gitnexus watch` 不会启动监视器。\n 本地工作区增量索引:gitnexus analyze --watch\n 定时远程 clone/pull 并分析:gitnexus auto-sync start\n', + 'error.watch.ambiguous': + '`gitnexus watch` 含义不明确。\n 本地工作区增量索引:gitnexus analyze --watch\n 定时远程 clone/pull 并分析:gitnexus auto-sync start\n', 'help.command.analyze.description': '索引仓库(完整分析)', 'help.command.index.description': '将现有 .gitnexus/ 文件夹注册到全局注册表(无需重新分析)', 'help.command.serve.description': '启动供 Web UI 连接的本地 HTTP 服务器', @@ -173,7 +200,8 @@ export const zhCN = { '重建时删除现有嵌入。默认情况下,未传 `--embeddings` 的 `analyze` 会保留索引中已有嵌入。', 'help.option.analyze.skills': '根据检测到的社区生成仓库专属 skill 文件(同时设置 --index-only 时无效)。', - 'help.option.analyze.skipAgentsMd': '跳过更新 AGENTS.md 和 CLAUDE.md 中的 gitnexus 区块', + 'help.option.analyze.skipAgentsMd': + '跳过更新 AGENTS.md 和 CLAUDE.md 中的 gitnexus 区块。不会跳过 .claude/skills 或 .agents/skills 下的标准 skill;如需跳过那些请使用 --skip-skills。--skills 生成的社区 skill 不受影响。', 'help.option.analyze.noStats': '从 AGENTS.md 和 CLAUDE.md 中省略易变的文件/符号计数', 'help.option.analyze.selfCommit': '在 analyze 后自动提交 AGENTS.md/CLAUDE.md 的变更(默认关闭,需显式开启)。仅限这两个文件(绝不使用 `git add -A`);若两者均不存在、均未变更,或仓库未配置 git 身份,则不执行任何操作。', @@ -197,6 +225,8 @@ export const zhCN = { 'help.option.analyze.embeddingBatchSize': '每个嵌入批次的节点数', 'help.option.analyze.embeddingSubBatchSize': '每次嵌入模型调用的分块数', 'help.option.analyze.embeddingDevice': '嵌入设备:auto、cpu、dml、cuda 或 wasm', + 'help.option.analyze.watch': '监视本地源文件变更并串行执行增量刷新', + 'help.option.analyze.debounce': '刷新前的静默等待时间(毫秒)', 'help.option.index.force': '即使缺少索引元数据也注册(统计为空)', 'help.option.index.allowNonGit': '允许注册非 Git 仓库文件夹', 'help.option.port': '端口号', @@ -205,7 +235,7 @@ export const zhCN = { 'help.option.mcp.host': 'HTTP 绑定地址(仅与 --http 搭配使用)。默认:127.0.0.1(回环)。使用 0.0.0.0 向所有接口开放。', 'help.option.mcp.authToken': - '要求 Authorization 头携带此 Bearer Token(仅与 --http 搭配使用);也可通过 GITNEXUS_MCP_AUTH_TOKEN 环境变量设置。非回环绑定(--host 0.0.0.0/::)时必填,否则拒绝启动。', + '要求 Authorization 头携带此 Bearer Token(仅与 --http 搭配使用);也可通过 GITNEXUS_MCP_AUTH_TOKEN 环境变量设置,该变量同时为 gitnexus serve 的 /api/mcp 路由启用 MCP Bearer 认证。非回环绑定(--host 0.0.0.0/::)时必填,否则拒绝启动。', 'help.option.force.confirmation': '跳过确认提示', 'help.option.uninstall.force': '应用更改(默认仅为预演预览)', 'help.option.clean.all': '清理所有已索引仓库', @@ -214,15 +244,14 @@ export const zhCN = { '清理已暂存的 LadybugDB 恢复 sidecar(missing-shadow WAL 隔离文件与 dirty-recovery 暂存文件)', 'help.option.wiki.force': '即使已是最新也强制完整重新生成', 'help.option.wiki.provider': - 'LLM 提供商:openai、openrouter、azure、custom、cursor、claude、codex 或 opencode(默认:openai)', - 'help.option.wiki.model': 'LLM 模型或 Azure deployment 名称(默认:minimax/minimax-m2.5)', + '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', 'help.option.wiki.apiKey': 'LLM API key 或 Azure api-key(保存到 ~/.gitnexus/config.json)', 'help.option.wiki.apiVersion': 'Azure api-version 查询参数,例如 2024-10-21(仅旧版 Azure API)', - 'help.option.wiki.reasoningModel': - '标记 deployment 为 reasoning model(o1/o3/o4-mini)— 去除 temperature,使用 max_completion_tokens', - 'help.option.wiki.noReasoningModel': '禁用 reasoning model 模式(覆盖已保存配置)', + 'help.option.wiki.reasoningModel': '启用 reasoning 模式;MiniMax-M3 使用自适应 thinking', + 'help.option.wiki.noReasoningModel': '禁用 reasoning 模式;MiniMax-M3 关闭 thinking', 'help.option.wiki.concurrency': '并行 LLM 调用数(默认:3)', 'help.option.wiki.timeout': 'LLM 请求超时时间(秒,默认:禁用)', 'help.option.wiki.retries': '每个请求的最大 LLM 重试次数(默认:3)', @@ -268,10 +297,9 @@ export const zhCN = { '同时下载 CUDA GPU 二进制文件(运行 onnxruntime-node 的 NuGet postinstall;代理后请设置 GLOBAL_AGENT_HTTPS_PROXY)', 'help.option.embeddings.install.force': '即使嵌入组件已可解析,也强制安装到运行时目录', 'help.option.group.create.force': '覆盖现有仓库组', - 'help.option.group.sync.skipEmbeddings': '仅使用 exact + BM25(不使用嵌入回退)', - 'help.option.group.sync.exactOnly': '仅精确匹配', - 'help.option.group.sync.allowStale': '跳过过期索引警告', - 'help.option.group.sync.verbose': '显示每条跨仓库链接详情', + 'help.option.group.sync.exactOnly': + '跳过通配符服务匹配,仅按契约 ID 精确匹配建立跨仓链接(清单声明的链接仍然生效)', + 'help.option.group.sync.verbose': '显示额外的同步诊断信息', 'help.option.status.json': '输出机器可读的索引和分析器来源信息', 'help.option.json': 'JSON 输出', 'help.option.group.impact.target': '要分析的符号或文件名', diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index 41aad4d6a..d48bc65c9 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -45,6 +45,22 @@ program .option('-f, --force', 'Apply the changes (default is a dry-run preview)') .action(createLazyAction(() => import('./uninstall.js'), 'uninstallCommand')); +program + .command('auto-sync [action]') + .description( + 'Control scheduled repository clone/pull and analysis from GITNEXUS_HOME/watch_config.yml', + ) + .addHelpText('after', () => t('help.autoSync.details')) + .action(createLazyAction(() => import('./auto-sync.js'), 'autoSyncCommand')); + +program + .command('watch [action]') + .description( + 'Ambiguous: use `analyze --watch` for local files, or `auto-sync` for scheduled remotes', + ) + .addHelpText('after', () => t('help.watch.details')) + .action(createLazyAction(() => import('./watch.js'), 'watchAmbiguousCommand')); + // Baseline of GITNEXUS_EMBEDDING_DIMS captured by the analyze preAction hook // before it overwrites the var, so the postAction hook can restore it. The // analyzeCommand env snapshot is taken AFTER this hook runs, so it cannot undo @@ -57,6 +73,8 @@ let dimsEnvCaptured = false; program .command('analyze [path]') .description('Index a repository (full analysis)') + .option('--watch', 'Keep the index current with serialized incremental refreshes') + .option('--debounce ', 'Watch quiet period before refreshing (default: 300 milliseconds)') .option('-f, --force', 'Force full re-index even if up to date') .option('--repair-fts', 'Repair/rebuild search FTS indexes without full re-analysis') .option( @@ -74,7 +92,10 @@ program 'Generate repo-specific skill files from detected communities ' + '(no-op when --index-only is also set).', ) - .option('--skip-agents-md', 'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md') + .option( + '--skip-agents-md', + 'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md. Does not skip standard skills in .claude/skills or .agents/skills; use --skip-skills for those. Community skills from --skills are unaffected.', + ) .option( '--pdg', 'Build the control-flow-graph / PDG substrate (BasicBlock nodes + CFG edges) ' + @@ -137,6 +158,11 @@ program '--workers ', 'Parse worker pool size (>=1). Default: cores-1 capped at 16, auto-sized to the repo.', ) + .option( + '--spring-actuator ', + 'Import local Spring Boot Actuator JSON snapshots (mappings, beans, conditions, ' + + 'configprops, env). Explicit opt-in; disabled by default.', + ) .option('--embedding-threads ', 'Limit local ONNX embedding CPU threads') .option('--embedding-batch-size ', 'Number of nodes per embedding batch') .option('--embedding-sub-batch-size ', 'Number of chunks per embedding model call') @@ -162,6 +188,11 @@ program ) .addHelpText('after', () => t('help.analyze.environment')) .hook('preAction', (thisCommand: Command) => { + const analyzeOpts = thisCommand.opts(); + if (analyzeOpts['debounce'] !== undefined && analyzeOpts['watch'] !== true) { + process.stderr.write('\n --debounce requires --watch\n\n'); + process.exit(1); + } // ONLY GITNEXUS_EMBEDDING_DIMS must be set here: schema.ts reads it at // module-load time during the lazy import('./analyze.js') below (via the // static chain analyze.ts → run-analyze.ts → schema.ts), so deferring to @@ -169,7 +200,7 @@ program // lazily at runtime (readConfig), so analyzeCommandImpl is their sole // setter — keeping them out of this hook means they fall under the impl's // env snapshot/restore and don't leak across in-process invocations. - const dimsOpt = thisCommand.opts()['embeddingDims']; + const dimsOpt = analyzeOpts['embeddingDims']; if (dimsOpt !== undefined) { // Validate + normalize BEFORE writing the env var: schema.ts throws on a // bad value at module-load, which — on the synchronous program.parse() @@ -202,7 +233,7 @@ program createAnalyzerLbugLazyAction( () => import('../core/analyzer-identity.js'), () => import('./analyze.js'), - 'analyzeCommandWithRunnerIdentity', + 'analyzeOrWatchCommandWithRunnerIdentity', import.meta.url, ), ); @@ -238,7 +269,7 @@ program ) .option( '--auth-token ', - '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,9 +334,9 @@ program .option('-f, --force', 'Force full regeneration even if up to date') .option( '--provider ', - 'LLM provider: openai, openrouter, azure, custom, cursor, claude, codex, or opencode (default: openai)', + 'LLM provider: minimax, openai, openrouter, azure, custom, cursor, claude, codex, opencode, or grok (default: minimax)', ) - .option('--model ', 'LLM model or Azure deployment name (default: minimax/minimax-m2.5)') + .option('--model ', 'LLM model or deployment name (default: MiniMax-M3)') .option( '--base-url ', 'LLM API base URL. Azure v1: https://{resource}.openai.azure.com/openai/v1', @@ -315,11 +346,8 @@ program '--api-version ', 'Azure api-version query param, e.g. 2024-10-21 (legacy Azure API only)', ) - .option( - '--reasoning-model', - 'Mark deployment as reasoning model (o1/o3/o4-mini) — strips temperature, uses max_completion_tokens', - ) - .option('--no-reasoning-model', 'Disable reasoning model mode (overrides saved config)') + .option('--reasoning-model', 'Enable reasoning mode; MiniMax-M3 uses adaptive thinking') + .option('--no-reasoning-model', 'Disable reasoning mode; MiniMax-M3 disables thinking') .option('--concurrency ', 'Parallel LLM calls (default: 3)', '3') .option('--timeout ', 'LLM request timeout in seconds (default: disabled)') .option('--retries ', 'Max LLM retry attempts per request (default: 3)') diff --git a/gitnexus/src/cli/setup.ts b/gitnexus/src/cli/setup.ts index 003e548a9..6dd0f9b85 100644 --- a/gitnexus/src/cli/setup.ts +++ b/gitnexus/src/cli/setup.ts @@ -1090,14 +1090,30 @@ async function installSkillsTo(targetDir: string): Promise { const skillDir = path.join(targetDir, skillName); try { - if (source.isDirectory) { - const dirSource = path.join(skillsRoot, skillName); - await copyDirRecursive(dirSource, skillDir); - } else { - const flatSource = path.join(skillsRoot, `${skillName}.md`); - const content = await fs.readFile(flatSource, 'utf-8'); + const sourceSkillPath = source.isDirectory + ? path.join(skillsRoot, skillName, 'SKILL.md') + : path.join(skillsRoot, `${skillName}.md`); + const destinationSkillPath = path.join(skillDir, 'SKILL.md'); + const [sourceSkillContent, destinationSkillContent] = await Promise.all([ + fs.readFile(sourceSkillPath, 'utf-8'), + fs.readFile(destinationSkillPath, 'utf-8').catch((err) => { + if (!isEnoent(err)) throw err; + return null; + }), + ]); + + const preserved = + destinationSkillContent !== null && destinationSkillContent !== sourceSkillContent; + if (preserved && !source.isDirectory) { + console.log( + `[gitnexus] preserved customized skill ${destinationSkillPath}; ` + + 'delete the file and rerun setup to refresh it.', + ); + } else if (source.isDirectory) { + await copyDirRecursive(path.join(skillsRoot, skillName), skillDir); + } else if (!preserved) { await fs.mkdir(skillDir, { recursive: true }); - await fs.writeFile(path.join(skillDir, 'SKILL.md'), content, 'utf-8'); + await fs.writeFile(destinationSkillPath, sourceSkillContent, 'utf-8'); } // A directory superseded by a shipped rename is warned about, never @@ -1113,7 +1129,7 @@ async function installSkillsTo(targetDir: string): Promise { ); } } - installed.push(skillName); + if (!preserved) installed.push(skillName); } catch { // Source skill not found — skip } @@ -1133,9 +1149,23 @@ async function copyDirRecursive(src: string, dest: string): Promise { const destPath = path.join(dest, entry.name); if (entry.isDirectory()) { await copyDirRecursive(srcPath, destPath); - } else { - await fs.copyFile(srcPath, destPath); + continue; } + const [srcBuf, destBuf] = await Promise.all([ + fs.readFile(srcPath), + fs.readFile(destPath).catch((err) => { + if (!isEnoent(err)) throw err; + return null; + }), + ]); + if (destBuf !== null && !destBuf.equals(srcBuf)) { + console.log( + `[gitnexus] preserved customized skill ${destPath}; ` + + 'delete the file and rerun setup to refresh it.', + ); + continue; + } + await fs.writeFile(destPath, srcBuf); } } diff --git a/gitnexus/src/cli/skill-gen.ts b/gitnexus/src/cli/skill-gen.ts index 9e46b1e5c..f1dd45c3b 100644 --- a/gitnexus/src/cli/skill-gen.ts +++ b/gitnexus/src/cli/skill-gen.ts @@ -14,6 +14,7 @@ import { CommunityNode, CommunityMembership } from '../core/ingestion/community- import { ProcessNode } from '../core/ingestion/process-processor.js'; import { KnowledgeGraph } from '../core/graph/types.js'; import { shouldMirrorSkillsToAgents } from './ai-context.js'; +import type { GeneratedSkillInfo } from './generated-skill.js'; const GENERATED_SKILL_PREFIX = 'gitnexus-area-'; const MAX_SKILL_NAME_LENGTH = 64; @@ -23,13 +24,6 @@ const MAX_COMMUNITY_NAME_LENGTH = MAX_SKILL_NAME_LENGTH - GENERATED_SKILL_PREFIX // TYPES // ============================================================================ -export interface GeneratedSkillInfo { - name: string; - label: string; - symbolCount: number; - fileCount: number; -} - interface AggregatedCommunity { label: string; rawIds: string[]; diff --git a/gitnexus/src/cli/status.ts b/gitnexus/src/cli/status.ts index 09eb415d3..e0ee08bcc 100644 --- a/gitnexus/src/cli/status.ts +++ b/gitnexus/src/cli/status.ts @@ -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): 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')}`); }; diff --git a/gitnexus/src/cli/tool.ts b/gitnexus/src/cli/tool.ts index 36a45651a..35f973d91 100644 --- a/gitnexus/src/cli/tool.ts +++ b/gitnexus/src/cli/tool.ts @@ -42,9 +42,18 @@ async function getBackend(): Promise { * and write directly to the real stdout fd (#324). * * Falls back to stderr if the fd write fails (e.g., broken pipe). + * + * `render` is for the commands that print prose instead of JSON: they hand over + * the STRUCTURED result and a formatter, so the payload stays visible to the + * exit-code test below — pre-formatting it into a string would hide the very + * fields that test reads. */ -function output(data: any): void { - const text = typeof data === 'string' ? data : JSON.stringify(data, null, 2); +function output(data: T, render?: (data: T) => string): void { + const text = render + ? render(data) + : typeof data === 'string' + ? data + : JSON.stringify(data, null, 2); try { writeSync(1, text + '\n'); } catch (err: any) { @@ -56,18 +65,34 @@ function output(data: any): void { // Fallback: stderr (previous behavior, works on all platforms) process.stderr.write(text + '\n'); } - // Backend failures come back as `{ error }` payloads rather than throws - // (#2469). Every tool command routes its result through here, so this is - // the one place that keeps scripted callers honest: print the payload, - // then exit non-zero. - if ( - data && - typeof data === 'object' && - 'error' in data && - typeof data.error === 'string' && - data.error.trim().length > 0 - ) { - process.exitCode = 1; + // Every tool command routes its result through here, so this is the one place + // that keeps scripted callers honest — `gitnexus impact … && ` and + // `gitnexus detect-changes && git commit` must not proceed on a result that + // did not complete. Two shapes say so, and both exit non-zero: + // + // • `error` — a backend failure, returned as a payload rather than thrown + // (#2469). + // • `partial` — a step failed and was SWALLOWED (#2915), so the counts and + // risk level are lower bounds a caller would otherwise read as clean. It + // is cross-tool vocabulary, not detect_changes' private flag: `query` + // raises it for degraded enrichment or a partial FTS failure, and `impact` + // for an interrupted traversal or capped per-symbol enrichment — a short + // caller set and an under-ranked risk, on the tool AGENTS.md makes a MUST + // gate before every edit. + // + // One code for both, because `&&` cannot tell two apart and a "softer" code + // for `partial` would invite exempting it again. + // + // NOT here: `truncated`, where only the LISTING is capped while the counts and + // risk are computed over the full set — the verdict is sound, so failing on it + // would fire on every large-but-healthy diff. Nor `partialProbe`, a narrower + // per-candidate flag on ambiguous impact targets. + if (data && typeof data === 'object') { + const payload = data as { error?: unknown; partial?: unknown }; + const failed = + (typeof payload.error === 'string' && payload.error.trim().length > 0) || + payload.partial === true; + if (failed) process.exitCode = 1; } } @@ -337,7 +362,9 @@ export async function detectChangesCommand(options?: { if (Array.isArray(result.affected_processes)) result.affected_processes = result.affected_processes.slice(0, limit); } - output(formatDetectChangesResult(result)); + // Hand over the structured result plus its formatter, not the formatted text: + // `output()` reads `error` / `partial` off the payload to set the exit code. + output(result, formatDetectChangesResult); } export async function checkCommand(options?: { @@ -359,21 +386,44 @@ export async function checkCommand(options?: { repo: options.repo, branch: options.branch, }); + // A rendering guard, not an exit-code decision — `output()` owns that. An + // error payload carries no `cycles` array, so the prose branch below would + // throw on it; print the structured payload and stop. if (result?.error) { output(result); - process.exitCode = 1; return; } if (options.json) { output(result); - } else if (result.cycleCount === 0) { + } else if (result.status === 'clean') { output('No circular imports found.'); } else { output( result.cycles.map((cycle: { files: string[] }) => cycle.files.join(' -> ')).join('\n'), ); + // Past the enumeration cap the tool reports one representative cycle per + // component instead of every elementary cycle. Say so, or the short list + // reads as the whole truth on exactly the repositories where it is not. + if (result.enumeration === 'component-representatives') { + // Phrased to need no plural: `checkCommand` predates the `t()` i18n + // layer and none of its output goes through it, so inventing a plural + // here by hand would be the only one in the file. + output( + `\n(showing one representative cycle per circular component — ` + + `${result.componentCount} in total; the full enumeration exceeded the safety limit.)`, + ); + } } - if (result.cycleCount > 0) process.exitCode = 1; + // Policy, not degradation: a clean run that FOUND cycles is `check` failing + // its own check, so `output()` — which fails closed on `error` and `partial` + // — deliberately knows nothing about it. + // + // Keyed on `status`, NOT on `cycleCount`. Past the enumeration cap the + // report carries `cycleCount: null` on purpose, because a partial count must + // not read as a real one — and `null > 0` is false, so counting here would + // exit 0 on precisely the repositories with the most cycles. `status` + // answers "were any found" in both enumeration modes. + if (result.status === 'cycles_found') process.exitCode = 1; } catch (error) { output({ error: error instanceof Error ? error.message : String(error) }); process.exitCode = 1; diff --git a/gitnexus/src/cli/watch-queue.ts b/gitnexus/src/cli/watch-queue.ts new file mode 100644 index 000000000..3f701ef50 --- /dev/null +++ b/gitnexus/src/cli/watch-queue.ts @@ -0,0 +1,184 @@ +export type WatchRefresh = (paths: readonly string[]) => Promise; +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(); + private readonly idleWaiters = new Set<() => void>(); + private timer: ReturnType | undefined; + private active: Promise | 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 { + 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 { + if (this.isIdle()) return; + await new Promise((resolve) => this.idleWaiters.add(resolve)); + } + + async close(): Promise { + 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 { + 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 { + let work: Promise; + 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(); + } +} diff --git a/gitnexus/src/cli/watch.ts b/gitnexus/src/cli/watch.ts new file mode 100644 index 000000000..2a779b51a --- /dev/null +++ b/gitnexus/src/cli/watch.ts @@ -0,0 +1,7 @@ +/** Reserved CLI verb: never starts either watch product. */ +import { t } from './i18n/index.js'; + +export async function watchAmbiguousCommand(_action?: string): Promise { + process.stderr.write(t('error.watch.ambiguous')); + process.exitCode = 1; +} diff --git a/gitnexus/src/cli/wiki.ts b/gitnexus/src/cli/wiki.ts index ef6776fbd..007451ef5 100644 --- a/gitnexus/src/cli/wiki.ts +++ b/gitnexus/src/cli/wiki.ts @@ -18,11 +18,14 @@ import { } from '../storage/repo-manager.js'; import { WikiGenerator, type WikiOptions } from '../core/wiki/generator.js'; import { + MINIMAX_MODEL_IDS, + MINIMAX_OPENAI_BASE_URLS, parseLLMAllowedInsecureHttpHosts, resolveLLMConfig, 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'; @@ -63,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}`); } @@ -216,11 +221,30 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions) ) { const existing = await loadCLIConfig(); const updates: Partial = {}; + const providerChanged = !!options.provider && options.provider !== existing.provider; + if (providerChanged) { + updates.apiKey = undefined; + updates.baseUrl = undefined; + updates.model = undefined; + updates.apiVersion = undefined; + updates.isReasoningModel = undefined; + } if (options.apiKey) updates.apiKey = options.apiKey; if (options.baseUrl) updates.baseUrl = options.baseUrl; if (options.provider) updates.provider = options.provider; if (options.apiVersion) updates.apiVersion = options.apiVersion; if (options.reasoningModel !== undefined) updates.isReasoningModel = options.reasoningModel; + if (options.provider === 'minimax') { + if (providerChanged && options.reasoningModel === undefined) { + updates.isReasoningModel = undefined; + } + if (!options.baseUrl && (providerChanged || !existing.baseUrl)) { + updates.baseUrl = MINIMAX_OPENAI_BASE_URLS.global_en; + } + if (!options.model && (providerChanged || !existing.model)) { + updates.model = MINIMAX_MODEL_IDS[0]; + } + } // Save model to appropriate field based on provider. if (options.model) { const targetProvider = options.provider ?? existing.provider; @@ -237,7 +261,7 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions) const savedConfig = await loadCLIConfig(); const hasSavedConfig = !!( isLocalProvider(savedConfig.provider) || - (savedConfig.apiKey && savedConfig.baseUrl) + (savedConfig.apiKey && (savedConfig.baseUrl || savedConfig.provider === 'minimax')) ); const hasCLIOverrides = !!( options?.apiKey || @@ -265,26 +289,27 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions) // Non-interactive mode — need either API key or Cursor CLI if (!llmConfig.apiKey && !isLocalProvider(llmConfig.provider)) { console.log(' Error: No LLM API key found.'); - console.log(' Set OPENAI_API_KEY or GITNEXUS_API_KEY environment variable,'); - console.log(' or pass --api-key , or use --provider cursor|claude|codex|opencode.\n'); + console.log(' Set MINIMAX_API_KEY, GITNEXUS_API_KEY, or OPENAI_API_KEY,'); + console.log( + ' or pass --api-key , or use --provider cursor|claude|codex|opencode|grok.\n', + ); process.exitCode = 1; return; } // Non-interactive with env var or cursor — just use it } else { console.log(" No LLM configured. Let's set it up.\n"); - console.log( - ' Supports OpenAI, OpenRouter, Azure, any OpenAI-compatible API, Cursor CLI, Claude CLI, Codex CLI, or OpenCode CLI.\n', - ); + console.log(' Supports MiniMax, OpenAI-compatible APIs, and local agent CLIs.\n'); // Check if local agent CLIs are available. const hasCursor = detectCursorCLI(); 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 @@ -292,7 +317,9 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions) console.log(' [2] OpenRouter (openrouter.ai)'); console.log(' [3] Azure OpenAI'); console.log(' [4] Custom endpoint'); - let nextChoice = 5; + console.log(' [5] MiniMax Global (api.minimax.io)'); + console.log(' [6] MiniMax China (api.minimaxi.com)'); + let nextChoice = 7; if (hasCursor) { const choice = String(nextChoice++); localChoices.push({ @@ -325,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); @@ -413,10 +448,10 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions) provider: 'azure', }; } else { - // OpenAI-compatible provider (OpenAI, OpenRouter, Custom) + // OpenAI-compatible provider setup if (choice === '2') { baseUrl = 'https://openrouter.ai/api/v1'; - defaultModel = 'minimax/minimax-m2.5'; + defaultModel = ''; provider = 'openrouter'; } else if (choice === '4') { baseUrl = await prompt(' Base URL (e.g. http://localhost:11434/v1): '); @@ -427,6 +462,11 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions) } defaultModel = 'gpt-4o-mini'; provider = 'custom'; + } else if (choice === '5' || choice === '6') { + baseUrl = + choice === '6' ? MINIMAX_OPENAI_BASE_URLS.cn_zh : MINIMAX_OPENAI_BASE_URLS.global_en; + defaultModel = MINIMAX_MODEL_IDS[0]; + provider = 'minimax'; } else { baseUrl = 'https://api.openai.com/v1'; defaultModel = 'gpt-4o-mini'; @@ -434,11 +474,22 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions) } // Model - const modelInput = await prompt(` Model (default: ${defaultModel}): `); + const modelInput = await prompt( + defaultModel ? ` Model (default: ${defaultModel}): ` : ' Model: ', + ); const model = modelInput || defaultModel; + if (!model) { + console.log('\n No model provided. Aborting.\n'); + process.exitCode = 1; + return; + } // API key — pre-fill hint if env var exists - const envKey = process.env.GITNEXUS_API_KEY || process.env.OPENAI_API_KEY || ''; + const envKey = + (provider === 'minimax' ? process.env.MINIMAX_API_KEY : undefined) || + process.env.GITNEXUS_API_KEY || + process.env.OPENAI_API_KEY || + ''; if (envKey) { const masked = envKey.slice(0, 6) + '...' + envKey.slice(-4); const useEnv = await prompt(` Use existing env key (${masked})? (Y/n): `); @@ -458,7 +509,15 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions) } // Save - await saveCLIConfig({ apiKey: key, baseUrl, model, provider }); + await saveCLIConfig({ + ...savedConfig, + apiKey: key, + baseUrl, + model, + provider, + apiVersion: undefined, + isReasoningModel: undefined, + }); console.log(' Config saved to ~/.gitnexus/config.json\n'); llmConfig = { ...llmConfig, apiKey: key, baseUrl, model, provider }; diff --git a/gitnexus/src/config/ignore-service.ts b/gitnexus/src/config/ignore-service.ts index 163998a8e..ac2dc7704 100644 --- a/gitnexus/src/config/ignore-service.ts +++ b/gitnexus/src/config/ignore-service.ts @@ -1,7 +1,9 @@ import ignore, { type Ignore } from 'ignore'; +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'; @@ -31,12 +33,15 @@ const DEFAULT_IGNORE_LIST = new Set([ // 'packages' removed - commonly used for monorepo source code (lerna, pnpm, yarn workspaces) 'venv', '.venv', - 'env', '.env', + // Bare `env/` can be application source or a Python virtual environment. + // Path-aware rules below prune it at the root and wherever pyvenv.cfg marks + // a virtual environment, while preserving ordinary nested source folders. '__pycache__', '.pytest_cache', '.mypy_cache', 'site-packages', + 'dist-packages', '.tox', 'eggs', '.eggs', @@ -54,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 `/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 `/_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', @@ -86,11 +111,11 @@ const DEFAULT_IGNORE_LIST = new Set([ // Generated/Compiled '.generated', - 'generated', 'auto-generated', + // Bare `generated/` can contain tracked source-of-truth code. Build output + // 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', @@ -106,6 +131,14 @@ const DEFAULT_IGNORE_LIST = new Set([ '__snapshots__', ]); +// Ambiguous names that conventionally denote generated artifacts only at the +// repository root. Nested directories with these names are frequently source +// modules (for example apps/web/src/env or packages/api/generated). +const ROOT_ARTIFACT_DIRECTORIES = new Set(['env', 'generated']); + +const isRootArtifactDirectory = (relativePath: string, name: string): boolean => + !relativePath.includes('/') && ROOT_ARTIFACT_DIRECTORIES.has(name); + const IGNORED_EXTENSIONS = new Set([ // Images '.png', @@ -290,6 +323,10 @@ export const shouldIgnorePath = (filePath: string): boolean => { const fileName = parts[parts.length - 1]; const fileNameLower = fileName.toLowerCase(); + if (parts.length > 0 && isRootArtifactDirectory(parts[0], parts[0])) { + return true; + } + // Laravel compiles Blade templates into generated PHP cache files under // storage/framework/views. Source templates live in resources/views and are // handled separately; compiled cache should not become source-of-truth. Keep @@ -329,10 +366,8 @@ export const shouldIgnorePath = (filePath: string): boolean => { if ( fileNameLower.includes('.bundle.') || fileNameLower.includes('.chunk.') || - fileNameLower.includes('.generated.') || - fileNameLower.endsWith('.d.ts') + fileNameLower.includes('.generated.') ) { - // TypeScript declaration files return true; } @@ -344,6 +379,20 @@ export const isHardcodedIgnoredDirectory = (name: string): boolean => { return DEFAULT_IGNORE_LIST.has(name); }; +/** Apply directory ignore rules that depend on repository-relative depth. */ +export const isHardcodedIgnoredDirectoryAtPath = ( + repoRoot: string, + directoryPath: string, +): boolean => { + const name = nodePath.basename(directoryPath); + if (isHardcodedIgnoredDirectory(name)) return true; + + const relative = nodePath.relative(repoRoot, directoryPath).replace(/\\/g, '/'); + if (isRootArtifactDirectory(relative, name)) return true; + + return name === 'env' && existsSync(nodePath.join(directoryPath, 'pyvenv.cfg')); +}; + /** * Load .gitignore and .gitnexusignore rules from the repo root. * Returns an `ignore` instance with all patterns, or null if no files found. @@ -353,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 ( @@ -394,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 @@ -496,8 +583,10 @@ export const createIgnoreFilter = async (repoPath: string, options?: IgnoreOptio // last-match-wins: `!__tests__/` + `__tests__/generated/` still // blocks descent into `__tests__/generated/`. if (ig && rel && hasExplicitUnignore(ig, rel) && !ig.ignores(rel + '/')) return false; - // Hardcoded list: block descent into well-known noise directories. - if (DEFAULT_IGNORE_LIST.has(p.name)) return true; + // Hardcoded and path-aware rules prune whole trees before glob walks them. + if (rel && isHardcodedIgnoredDirectoryAtPath(repoPath, nodePath.join(repoPath, rel))) { + return true; + } // Check against .gitignore / .gitnexusignore patterns. // Since childrenIgnored is only called for directories, always test with // a trailing slash. This ensures directory-only negation patterns (e.g. diff --git a/gitnexus/src/config/repo-control-file.ts b/gitnexus/src/config/repo-control-file.ts new file mode 100644 index 000000000..13e08adb0 --- /dev/null +++ b/gitnexus/src/config/repo-control-file.ts @@ -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 { + 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((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; + } +} diff --git a/gitnexus/src/core/analysis-feature-registry.ts b/gitnexus/src/core/analysis-feature-registry.ts new file mode 100644 index 000000000..474c2157d --- /dev/null +++ b/gitnexus/src/core/analysis-feature-registry.ts @@ -0,0 +1,26 @@ +import { CLASS_FRAMEWORK_ANNOTATIONS_FEATURE } from './analysis-features.js'; +import { + SPRING_AOP_FEATURE, + SPRING_BEAN_INVENTORY_FEATURE, + SPRING_CONDITIONALS_FEATURE, + SPRING_NON_HTTP_HANDLERS_FEATURE, + SPRING_ROUTE_BINDINGS_FEATURE, +} from './ingestion/frameworks/spring/analysis-features.js'; +import { + JAVA_ENUM_INTERFACE_HERITAGE_FEATURE, + JAVA_RECORD_COMPONENT_ACCESSORS_FEATURE, + SPRING_CONFIG_BINDINGS_FEATURE, +} from './ingestion/languages/java/analysis-features.js'; + +/** Production registry of independently versioned analysis capabilities. */ +export const ANALYSIS_FEATURES = [ + CLASS_FRAMEWORK_ANNOTATIONS_FEATURE, + SPRING_AOP_FEATURE, + SPRING_BEAN_INVENTORY_FEATURE, + SPRING_CONDITIONALS_FEATURE, + SPRING_NON_HTTP_HANDLERS_FEATURE, + SPRING_ROUTE_BINDINGS_FEATURE, + SPRING_CONFIG_BINDINGS_FEATURE, + JAVA_ENUM_INTERFACE_HERITAGE_FEATURE, + JAVA_RECORD_COMPONENT_ACCESSORS_FEATURE, +] as const; diff --git a/gitnexus/src/core/analyzer-identity.ts b/gitnexus/src/core/analyzer-identity.ts index 6bc80848f..628f27716 100644 --- a/gitnexus/src/core/analyzer-identity.ts +++ b/gitnexus/src/core/analyzer-identity.ts @@ -15,6 +15,7 @@ */ import { + accessSync, closeSync, constants as fsConstants, existsSync, @@ -38,6 +39,7 @@ import { spawnSync } from 'node:child_process'; import { isDeepStrictEqual } from 'node:util'; import os from 'node:os'; import path from 'node:path'; +import { parseTruthyEnv } from './ingestion/utils/env.js'; import { fileURLToPath } from 'node:url'; import type { AnalyzerRunnerIdentity } from '../storage/repo-manager.js'; @@ -2335,8 +2337,30 @@ function snapshotCacheGuardDirect(request: CacheGuardRequest): CacheGuardResult } } -function snapshotCacheGuards(requests: CacheGuardRequest[]): CacheGuardResult[] { +function installTreeUnwritable(packageRoot: string, buildRoot: string): boolean { + for (const dir of [packageRoot, buildRoot]) { + try { + accessSync(dir, fsConstants.W_OK); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === 'EACCES' || code === 'EROFS') return true; + } + } + return false; +} + +function snapshotCacheGuards( + requests: CacheGuardRequest[], + packageRoot: string, + buildRoot: string, +): CacheGuardResult[] { if (requests.length < 128) return requests.map(snapshotCacheGuardDirect); + if ( + parseTruthyEnv(process.env.GITNEXUS_ANALYZER_IDENTITY_IN_PROCESS_GUARDS) || + installTreeUnwritable(packageRoot, buildRoot) + ) { + return requests.map(snapshotCacheGuardDirect); + } try { const probe = spawnSync( process.execPath, @@ -2468,7 +2492,7 @@ function validateIdentityCache( return { mode, absolutePath }; }); options.onCacheValidationPass?.({ guardCount: requests.length }); - const actual = snapshotCacheGuards(requests); + const actual = snapshotCacheGuards(requests, cache.packageRoot, cache.buildRoot); const mismatch = actual.findIndex( (result, index) => !isDeepStrictEqual(result, entries[index][1]), ); diff --git a/gitnexus/src/core/auto-sync/analysis-worker-launch.ts b/gitnexus/src/core/auto-sync/analysis-worker-launch.ts new file mode 100644 index 000000000..d57a251a6 --- /dev/null +++ b/gitnexus/src/core/auto-sync/analysis-worker-launch.ts @@ -0,0 +1,210 @@ +import { fork, type ChildProcess } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import type { AnalyzeOptions, AnalyzeResult } from '../run-analyze.js'; +import type { WorkerMessage } from '../../server/analyze-worker-protocol.js'; +import { autoHeapCapMb } from '../ingestion/utils/effective-ram.js'; + +const _require = createRequire(import.meta.url); +export type AutoSyncAnalysisRunner = ( + repoPath: string, + options: AnalyzeOptions, + timeoutMs: number, + signal?: AbortSignal, + onCancellationRequested?: () => void, + concurrency?: number, +) => Promise>; + +interface AnalysisWorker extends Pick { + stdout?: Pick | null; + stderr?: Pick | null; + unref?: () => void; + channel?: { unref(): void } | null; +} + +/** + * How long the parent keeps waiting after asking a worker to cancel. + * + * Must stay below `stopAutoSyncWatch`'s process-exit budget, or a worker wedged + * past its safe point still turns `watch stop` into a timeout. + */ +const AUTO_SYNC_CANCEL_GRACE_MS = 5_000; + +export interface AutoSyncAnalysisLaunchDeps { + forkWorker: (workerPath: string, execArgv: string[]) => AnalysisWorker; + setTimeoutFn: typeof setTimeout; + clearTimeoutFn: typeof clearTimeout; + cancelGraceMs: number; +} + +const DEFAULT_DEPS: AutoSyncAnalysisLaunchDeps = { + forkWorker: (workerPath, execArgv) => + fork(workerPath, [], { + execArgv, + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], + }), + setTimeoutFn: setTimeout, + clearTimeoutFn: clearTimeout, + cancelGraceMs: AUTO_SYNC_CANCEL_GRACE_MS, +}; + +/** + * Per-worker V8 heap cap for one tick. + * + * `autoHeapCapMb()` is a whole-machine figure, so handing it to every fork + * over-commits memory by the parallelism factor. Admission already bounds + * parallelism to `floor(availableMemoryGB / 2)`, so dividing here keeps the sum + * of worker heaps inside the machine budget while leaving `max_concurrency` + * free to mean what it says. The two rules compose to a ~1.5GB per-worker floor. + */ +export function resolveWorkerHeapMb(concurrency = 1): number { + const slots = Number.isFinite(concurrency) && concurrency >= 1 ? Math.floor(concurrency) : 1; + return Math.max(1, Math.min(8192, Math.floor(autoHeapCapMb() / slots))); +} + +export function createAutoSyncAnalysisRunner( + overrides: Partial = {}, +): AutoSyncAnalysisRunner { + const deps = { ...DEFAULT_DEPS, ...overrides }; + return (repoPath, options, timeoutMs, signal, onCancellationRequested, concurrency) => + new Promise>((resolve, reject) => { + if (signal?.aborted) { + reject(new Error('Analysis cancelled.')); + return; + } + const callerPath = fileURLToPath(import.meta.url); + const isDev = callerPath.endsWith('.ts'); + const workerPath = path.join( + path.dirname(callerPath), + '../../server', + isDev ? 'analyze-worker.ts' : 'analyze-worker.js', + ); + if (!existsSync(workerPath)) { + reject(new Error(`Auto-sync analyze worker is missing: ${workerPath}`)); + return; + } + const workerHeapMb = resolveWorkerHeapMb(concurrency); + const execArgv = isDev + ? [ + '--import', + pathToFileURL(_require.resolve('tsx/esm')).href, + `--max-old-space-size=${workerHeapMb}`, + ] + : [`--max-old-space-size=${workerHeapMb}`]; + const child = deps.forkWorker(workerPath, execArgv); + child.stdout?.resume(); + child.stderr?.resume(); + + let terminalOutcome: WorkerMessage | undefined; + let terminationError: Error | undefined; + let settled = false; + let graceTimer: ReturnType | undefined; + const cleanup = () => { + deps.clearTimeoutFn(timeout); + deps.clearTimeoutFn(graceTimer); + signal?.removeEventListener('abort', onAbort); + }; + // Stop the parent owning a worker it has given up waiting for. An + // established IPC channel keeps this event loop alive even after unref, + // so both handles have to go. Never a kill: the child may be inside + // native work and is left to reach its own safe point. + const releaseChild = () => { + child.channel?.unref?.(); + child.unref?.(); + }; + const settle = (error?: Error, result?: Pick) => { + if (settled) return; + settled = true; + cleanup(); + if (error) reject(error); + else resolve(result!); + }; + const requestCancellation = (error: Error) => { + if (settled || terminationError) return; + terminationError = error; + deps.clearTimeoutFn(timeout); + onCancellationRequested?.(); + // IPC has the same semantics on macOS and Windows. The worker exits only + // after reaching a JS-visible safe point; this parent keeps ownership until then. + try { + child.send({ type: 'cancel' }); + } catch { + // A closed IPC channel still has an exit/error path. Do not force-kill a + // worker that may be inside native code. + } + // Bounded wait. A worker stuck past its safe point would otherwise leave + // this promise pending forever, wedging `activeRun` so `stop()` — and the + // `watch stop` waiting on this process to exit — can never finish. Settle + // the parent's wait and drop the IPC channel's hold on this event loop; + // an established channel keeps the parent alive even after unref. The + // child is deliberately left running rather than killed mid-write. + graceTimer = deps.setTimeoutFn(() => { + if (settled) return; + releaseChild(); + settle( + new Error( + `${error.message} The analyze worker did not exit within ${deps.cancelGraceMs}ms; ` + + 'it was left running so its native work is not interrupted.', + ), + ); + }, deps.cancelGraceMs); + }; + const timeout = deps.setTimeoutFn( + () => requestCancellation(new Error(`Analysis timed out after ${timeoutMs}ms.`)), + timeoutMs, + ); + const onAbort = () => requestCancellation(new Error('Analysis cancelled.')); + signal?.addEventListener('abort', onAbort, { once: true }); + + child.on('message', (message: WorkerMessage) => { + // Once timeout/cancellation requested shutdown, its reason owns the + // result. A terminal IPC can already be queued behind cancellation. + if (message.type === 'progress' || terminalOutcome || terminationError) return; + terminalOutcome = message; + deps.clearTimeoutFn(timeout); + }); + child.on('error', (error) => { + const workerError = new Error(`Auto-sync analyze worker error: ${error.message}`); + requestCancellation(workerError); + // This settles immediately rather than waiting out the grace, so the + // grace timer that would otherwise have released the child is cleared + // by cleanup(). Release it here instead — an errored channel does not + // mean the worker stopped. + releaseChild(); + settle(workerError); + }); + child.on('exit', (code, childSignal) => { + if (settled) return; + if (terminationError) { + settle(terminationError); + return; + } + if (terminalOutcome?.type === 'complete') { + settle(undefined, { stats: terminalOutcome.result.stats }); + return; + } + if (terminalOutcome?.type === 'error') { + settle(new Error(terminalOutcome.message)); + return; + } + settle( + new Error( + `Auto-sync analyze worker exited before completion (${childSignal ?? code ?? 'unknown'}).`, + ), + ); + }); + try { + child.send({ type: 'start', repoPath, options }); + } catch (error) { + const startError = new Error( + `Failed to start auto-sync analyze worker: ${(error as Error).message}`, + ); + requestCancellation(startError); + settle(startError); + } + }); +} + +export const runAutoSyncAnalysis = createAutoSyncAnalysisRunner(); diff --git a/gitnexus/src/core/auto-sync/config.ts b/gitnexus/src/core/auto-sync/config.ts new file mode 100644 index 000000000..fa91aa8b4 --- /dev/null +++ b/gitnexus/src/core/auto-sync/config.ts @@ -0,0 +1,367 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { getGlobalDir } from '../../storage/repo-manager.js'; +import { normalizeConfiguredCloneRoot } from './path-security.js'; + +const _require = createRequire(import.meta.url); +const yaml = _require('js-yaml') as typeof import('js-yaml'); + +export const AUTO_SYNC_CONFIG_FILE = 'watch_config.yml'; +const GROUP_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]*$/; +const MIN_SYNC_INTERVAL_MINUTES = 5; +const MAX_TIMER_DELAY_MS = 2_147_483_647; +const MAX_SYNC_INTERVAL_MINUTES = Math.floor(MAX_TIMER_DELAY_MS / 60_000); +const DEFAULT_REPO_GIT_TIMEOUT_MS = 10_000; +const DEFAULT_MAX_CONCURRENCY = 1; +export const DEFAULT_ANALYZE_FAILURE_THRESHOLD = 3; +const MIN_ANALYZE_FAILURE_THRESHOLD = 2; +const ALLOWED_REMOTE_HOSTS = new Set(['github.com', 'gitlab.com', 'gitee.com']); + +/** + * A single clone/pull must fit inside one sync interval and inside an hour. + * This is also the guard for the unit slip the bare-number rule invites: + * `repo_git_timeout: 600000` means 600000 SECONDS (~7 days), which clears the + * Node timer ceiling and would silently disable the timeout. + */ +const MAX_REPO_GIT_TIMEOUT_MS = 3_600_000; + +// Mirrors REPO_NAME_PATTERN in server/git-clone.ts. Deliberately duplicated +// rather than imported: git-clone.ts already imports from this module, so the +// reverse edge would be a cycle. +const REMOTE_REPO_NAME_PATTERN = /^[a-zA-Z0-9._-]+$/; +// Same charset for a namespace segment: GitLab subgroups allow exactly these, +// and excluding separators is what stops a segment smuggling in traversal. +const REMOTE_PATH_SEGMENT_PATTERN = REMOTE_REPO_NAME_PATTERN; + +export interface AutoSyncProjectConfig { + localPath: string; + groupName?: string; + overwriteLocalChanges: boolean; + branches: string[]; + remoteUrls: string[]; +} + +export interface AutoSyncConfig { + configPath: string; + syncIntervalMinutes: number; + repoGitTimeoutMs: number; + analyzeTimeoutMs: number; + maxConcurrency: number; + analyzeFailureThreshold: number; + projects: AutoSyncProjectConfig[]; +} + +export type AutoSyncConfigLoadResult = + | { ok: true; config: AutoSyncConfig } + | { ok: false; reason: 'missing' | 'unreadable' | 'invalid'; message: string }; + +export function getAutoSyncConfigPath(gitnexusDir = getGlobalDir()): string { + return path.join(gitnexusDir, AUTO_SYNC_CONFIG_FILE); +} + +export function parseBranchCandidates(branchValue: unknown): string[] { + const rawItems = Array.isArray(branchValue) + ? branchValue.flatMap((item) => String(item).split(',')) + : String(branchValue ?? '').split(','); + const branches: string[] = []; + const seen = new Set(); + for (const item of rawItems) { + const branch = item.trim(); + if (!branch || seen.has(branch)) continue; + seen.add(branch); + branches.push(branch); + } + return branches; +} + +export async function loadAutoSyncConfig( + configPath = getAutoSyncConfigPath(), +): Promise { + let content: string; + try { + content = await fs.readFile(configPath, 'utf-8'); + } catch (err: unknown) { + const code = (err as NodeJS.ErrnoException).code; + if (code === 'ENOENT') { + return { + ok: false, + reason: 'missing', + message: `[auto-sync] Missing config file: ${configPath}. Auto sync is skipped.`, + }; + } + return { + ok: false, + reason: 'unreadable', + message: `[auto-sync] Unable to read config file: ${configPath}. Auto sync is skipped.`, + }; + } + + try { + return { ok: true, config: parseAutoSyncConfig(content, configPath) }; + } catch (err: unknown) { + return { + ok: false, + reason: 'invalid', + message: `[auto-sync] Invalid watch_config.yml: ${(err as Error).message}. Auto sync is skipped.`, + }; + } +} + +export function parseAutoSyncConfig(content: string, configPath: string): AutoSyncConfig { + const raw = yaml.load(content, { schema: yaml.JSON_SCHEMA }) as Record; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + throw new Error('expected a YAML object'); + } + + const errors: string[] = []; + const interval = Number(raw.sync_interval_minutes); + if (!Number.isInteger(interval) || interval <= 0) { + errors.push('sync_interval_minutes must be a positive integer'); + } else if (interval < MIN_SYNC_INTERVAL_MINUTES) { + errors.push(`sync_interval_minutes must be at least ${MIN_SYNC_INTERVAL_MINUTES}`); + } else if (interval > MAX_SYNC_INTERVAL_MINUTES) { + errors.push(`sync_interval_minutes must not exceed ${MAX_SYNC_INTERVAL_MINUTES}`); + } + + // YAML booleans survive JSON_SCHEMA (`true`/`false`). `Number(true) === 1` + // would otherwise pass the integer check and silently mean concurrency 1. + let maxConcurrency = DEFAULT_MAX_CONCURRENCY; + if (raw.max_concurrency !== undefined) { + if (typeof raw.max_concurrency !== 'number' || !Number.isInteger(raw.max_concurrency)) { + errors.push('max_concurrency must be a positive integer'); + } else if (raw.max_concurrency <= 0) { + errors.push('max_concurrency must be a positive integer'); + } else { + maxConcurrency = raw.max_concurrency; + } + } + + const repoGitTimeoutMs = + raw.repo_git_timeout === undefined + ? DEFAULT_REPO_GIT_TIMEOUT_MS + : parseDurationMs(raw.repo_git_timeout); + const maxRepoGitTimeoutMs = + Number.isInteger(interval) && + interval >= MIN_SYNC_INTERVAL_MINUTES && + interval <= MAX_SYNC_INTERVAL_MINUTES + ? Math.min(interval * 60_000, MAX_REPO_GIT_TIMEOUT_MS) + : undefined; + if (!Number.isInteger(repoGitTimeoutMs) || repoGitTimeoutMs <= 0) { + errors.push('repo_git_timeout must be a positive duration such as 10s'); + } else if (repoGitTimeoutMs > MAX_TIMER_DELAY_MS) { + errors.push(`repo_git_timeout must not exceed ${MAX_TIMER_DELAY_MS}ms`); + } else if (maxRepoGitTimeoutMs !== undefined && repoGitTimeoutMs > maxRepoGitTimeoutMs) { + errors.push( + `repo_git_timeout must not exceed ${maxRepoGitTimeoutMs}ms (the lesser of 1h and ` + + `sync_interval_minutes); a bare number is interpreted as seconds, so use an explicit ` + + `unit such as 600000ms or 10m`, + ); + } + + const maxAnalyzeTimeoutMs = + Number.isInteger(interval) && + interval >= MIN_SYNC_INTERVAL_MINUTES && + interval <= MAX_SYNC_INTERVAL_MINUTES + ? interval * 30_000 + : undefined; + const analyzeTimeoutMs = + raw.analyze_timeout === undefined + ? (maxAnalyzeTimeoutMs ?? 0) + : parseDurationMs(raw.analyze_timeout); + if (!Number.isInteger(analyzeTimeoutMs) || analyzeTimeoutMs <= 0) { + errors.push('analyze_timeout must be a positive duration such as 30m'); + } else if (maxAnalyzeTimeoutMs !== undefined && analyzeTimeoutMs > maxAnalyzeTimeoutMs) { + errors.push( + `analyze_timeout must not exceed half of sync_interval_minutes (${maxAnalyzeTimeoutMs / 60_000}m)`, + ); + } + + const analyzeFailureThreshold = + raw.analyze_failure_threshold === undefined + ? DEFAULT_ANALYZE_FAILURE_THRESHOLD + : Number(raw.analyze_failure_threshold); + if ( + !Number.isInteger(analyzeFailureThreshold) || + analyzeFailureThreshold < MIN_ANALYZE_FAILURE_THRESHOLD + ) { + errors.push(`analyze_failure_threshold must be an integer >= ${MIN_ANALYZE_FAILURE_THRESHOLD}`); + } + + const rawProjects = raw.projects; + if (!Array.isArray(rawProjects) || rawProjects.length === 0) { + errors.push('projects must contain at least one project'); + } + + const projects: AutoSyncProjectConfig[] = []; + if (Array.isArray(rawProjects)) { + rawProjects.forEach((projectValue, index) => { + const project = projectValue as Record; + if (!project || typeof project !== 'object' || Array.isArray(project)) { + errors.push(`projects[${index}] must be an object`); + return; + } + + const localPath = typeof project.local_path === 'string' ? project.local_path.trim() : ''; + if (!localPath) { + errors.push(`projects[${index}].local_path is required`); + } else { + try { + normalizeConfiguredCloneRoot(localPath); + } catch (err: unknown) { + errors.push(`projects[${index}].local_path ${(err as Error).message}`); + } + } + + const remoteUrls = Array.isArray(project.remote_urls) + ? project.remote_urls.map((url) => String(url).trim()).filter(Boolean) + : []; + if (remoteUrls.length === 0) { + errors.push(`projects[${index}].remote_urls must contain at least one URL`); + } + for (let urlIndex = 0; urlIndex < remoteUrls.length; urlIndex += 1) { + try { + validateAutoSyncRemoteUrl(remoteUrls[urlIndex]); + } catch (err: unknown) { + errors.push(`projects[${index}].remote_urls[${urlIndex}] ${(err as Error).message}`); + } + } + + if (project.branch !== undefined && project.branches !== undefined) { + errors.push(`projects[${index}] must not set both branch and branches`); + } + const branches = parseBranchCandidates( + project.branches !== undefined ? project.branches : project.branch, + ); + if (branches.length === 0) errors.push(`projects[${index}].branches is required`); + for (let branchIndex = 0; branchIndex < branches.length; branchIndex += 1) { + try { + validateAutoSyncBranchName(branches[branchIndex]); + } catch (err: unknown) { + errors.push(`projects[${index}].branches[${branchIndex}] ${(err as Error).message}`); + } + } + + const groupName = + typeof project.group_name === 'string' && project.group_name.trim() + ? project.group_name.trim() + : undefined; + if (groupName && !GROUP_NAME_PATTERN.test(groupName)) { + errors.push(`projects[${index}].group_name is invalid`); + } + + const overwriteLocalChanges = + project.overwrite_local_changes === undefined ? false : project.overwrite_local_changes; + if (typeof overwriteLocalChanges !== 'boolean') { + errors.push(`projects[${index}].overwrite_local_changes must be a boolean`); + } + + if (localPath && remoteUrls.length > 0 && branches.length > 0) { + projects.push({ + localPath, + groupName, + overwriteLocalChanges: overwriteLocalChanges === true, + branches, + remoteUrls, + }); + } + }); + } + + if (errors.length > 0) throw new Error(errors.join('; ')); + return { + configPath, + syncIntervalMinutes: interval, + repoGitTimeoutMs, + analyzeTimeoutMs, + maxConcurrency, + analyzeFailureThreshold, + projects, + }; +} + +export function validateAutoSyncRemoteUrl(remoteUrl: string): void { + const trimmed = remoteUrl.trim(); + if (trimmed.includes('?') || trimmed.includes('#')) { + throw new Error('must not include query strings or fragments'); + } + const match = /^git@([^:\s/]+):([^\s]+)$/.exec(trimmed); + if (!match) { + throw new Error('must use an SSH URL on github.com, gitlab.com, or gitee.com'); + } + const host = match[1].toLowerCase(); + const repoPath = match[2]; + if (!ALLOWED_REMOTE_HOSTS.has(host)) { + throw new Error('host must be one of github.com, gitlab.com, or gitee.com'); + } + const pathParts = repoPath.split('/'); + // Every segment becomes a directory component: the namespace segments build + // the clone path and the last one names the repo. So each is held to the same + // charset, which is what keeps a separator out of a segment — on Windows + // `..\..\outside` is traversal even though the segment is not literally `..`, + // and testing the raw string for `..` instead would reject an ordinary + // `foo..bar`. Traversal is a whole segment; a separator is a character. + const namespaceParts = pathParts.slice(0, -1); + if ( + repoPath.startsWith('/') || + pathParts.length < 2 || + pathParts.some((part) => !part || part === '.' || part === '..') || + namespaceParts.some((part) => !REMOTE_PATH_SEGMENT_PATTERN.test(part)) + ) { + throw new Error('path must include owner/repo without traversal'); + } + // The final segment becomes the on-disk clone directory via `extractRepoName`, + // whose name rules are stricter than the path check above: a backslash — or + // anything outside `[A-Za-z0-9._-]` — passes here and then throws once per + // tick inside the sync loop instead of at config load. These rules are a + // strict superset, so anything accepted here is accepted there. + const lastSegment = pathParts[pathParts.length - 1]; + const repoName = /\.git$/i.test(lastSegment) ? lastSegment.slice(0, -4) : lastSegment; + if ( + !repoName || + repoName === '.' || + repoName === '..' || + repoName === 'unknown' || + repoName.startsWith('-') || + !REMOTE_REPO_NAME_PATTERN.test(repoName) + ) { + throw new Error( + 'repository name must use only letters, digits, ".", "_", or "-" and must not be "unknown"', + ); + } +} + +export function validateAutoSyncBranchName(branch: string): void { + if (!branch.trim()) throw new Error('must not be empty'); + if (/[\s\0-\x1f\x7f]/.test(branch)) + throw new Error('must not contain whitespace or control characters'); + if (/[~^:?*[\\]/.test(branch)) throw new Error('contains characters not allowed in a git ref'); + if (branch.startsWith('-')) throw new Error('must not start with "-"'); + if (branch.startsWith('/')) throw new Error('must not start with "/"'); + if (branch.includes('..')) throw new Error('must not contain ".."'); + if (branch.includes('`')) throw new Error('must not contain backticks'); + if (branch.endsWith('/') || branch.endsWith('.')) throw new Error('must not end with "/" or "."'); + if (branch.includes('//')) throw new Error('must not contain consecutive slashes'); + if (branch.includes('@{')) throw new Error('must not contain "@{"'); + if ( + branch + .split('/') + .some( + (component) => + component.startsWith('.') || component.endsWith('.') || component.endsWith('.lock'), + ) + ) + throw new Error('must not contain hidden, trailing-dot, or .lock path components'); +} + +export function parseDurationMs(value: unknown): number { + if (typeof value === 'number') return value * 1_000; + const raw = String(value ?? '').trim(); + const match = /^(\d+)(ms|s|m)?$/.exec(raw); + if (!match) return Number.NaN; + const amount = Number(match[1]); + const unit = match[2] ?? 's'; + if (unit === 'ms') return amount; + if (unit === 's') return amount * 1_000; + return amount * 60_000; +} diff --git a/gitnexus/src/core/auto-sync/index.ts b/gitnexus/src/core/auto-sync/index.ts new file mode 100644 index 000000000..b02948779 --- /dev/null +++ b/gitnexus/src/core/auto-sync/index.ts @@ -0,0 +1,57 @@ +export { + AUTO_SYNC_CONFIG_FILE, + getAutoSyncConfigPath, + loadAutoSyncConfig, + parseAutoSyncConfig, + parseBranchCandidates, + parseDurationMs, + validateAutoSyncBranchName, + validateAutoSyncRemoteUrl, + type AutoSyncConfig, + type AutoSyncConfigLoadResult, + type AutoSyncProjectConfig, +} from './config.js'; +export { + buildStateKey, + getAutoSyncMutexPath, + getAutoSyncWatchDir, + getAutoSyncStatePath, + getProjectCommitInfoPath, + loadAutoSyncState, + resetAutoSyncState, + saveAutoSyncState, + shouldAnalyzeCommit, + writeProjectCommitInfo, + type AutoSyncAnalyzeStatus, + type AutoSyncCommitState, + type AutoSyncCommitStateEntry, + type ProjectCommitInfoEntry, +} from './state.js'; +export { extractRepoNameFromRemoteUrl } from './repo.js'; +export { + normalizeConfiguredCloneRoot, + quarantineAutoSyncPartial, + resolveConfiguredCloneRoot, + type AutoSyncCloneRoot, +} from './path-security.js'; +export { + addRepoToGroup, + getAutoSyncRepoIdentity, + getConfiguredRepoPath, + resolveActualConcurrency, + runAutoSyncOnce, + syncGroupByName, + type AutoSyncLogger, + type AutoSyncRunDeps, + type AutoSyncRunResult, +} from './runner.js'; +export { + getAutoSyncWatchPaths, + readAutoSyncWatchStatus, + startAutoSyncWatch, + stopAutoSyncWatch, + type AutoSyncStartHandle, + type AutoSyncWatchStopResult, + type AutoSyncWatchPaths, + type WatchStatusRecord, +} from './starter.js'; diff --git a/gitnexus/src/core/auto-sync/path-security.ts b/gitnexus/src/core/auto-sync/path-security.ts new file mode 100644 index 000000000..ed9d9201b --- /dev/null +++ b/gitnexus/src/core/auto-sync/path-security.ts @@ -0,0 +1,286 @@ +import fs from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; +import os from 'node:os'; +import path from 'node:path'; +import { getGlobalDir } from '../../storage/repo-manager.js'; +import { getAutoSyncWatchDir } from './state.js'; + +const WINDOWS_DANGEROUS_ROOTS = + process.platform === 'win32' + ? [ + process.env.SystemRoot, + process.env.ProgramData, + process.env.ProgramFiles, + process.env['ProgramFiles(x86)'], + ].filter((entry): entry is string => Boolean(entry)) + : []; + +const DANGEROUS_ROOTS = new Set( + [ + '/', + os.homedir(), + os.tmpdir(), + '/bin', + '/boot', + '/dev', + '/etc', + '/lib', + '/lib64', + '/opt', + '/proc', + '/private/tmp', + '/private/var', + '/root', + '/sbin', + '/sys', + '/tmp', + '/usr', + '/var', + ...WINDOWS_DANGEROUS_ROOTS, + ].map((entry) => path.resolve(entry)), +); + +const DANGEROUS_PARENT_ROOTS = new Set( + [ + os.tmpdir(), + '/bin', + '/boot', + '/dev', + '/etc', + '/lib', + '/lib64', + '/opt', + '/proc', + '/private/tmp', + '/private/var', + '/root', + '/sbin', + '/sys', + '/tmp', + '/usr', + '/var', + ...WINDOWS_DANGEROUS_ROOTS, + ].map((entry) => path.resolve(entry)), +); + +const QUARANTINE_RETENTION_DAYS = 14; +const QUARANTINE_MAX_ENTRIES_PER_REPO = 5; + +// `auto-sync----` — see quarantineAutoSyncPartial. +// The UUID is the only fixed-shape field, so it anchors the grouping key, and +// everything after it is the basename (`[A-Za-z0-9._-]` by construction). +const QUARANTINE_ENTRY_PATTERN = + /^auto-sync-.+-\d+-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}-(.+)$/i; + +export interface AutoSyncCloneRoot { + root: string; + quarantineRoot: string; + quarantineRetentionDays: number; +} + +export async function resolveConfiguredCloneRoot(localPath: string): Promise { + const root = normalizeConfiguredCloneRoot(localPath); + assertNotDangerousRoot(root); + await assertNoSymlinkPath(root); + await fs.mkdir(root, { recursive: true }); + await assertDirectoryOwnerAndPermissions(root); + const realRoot = await fs.realpath(root); + assertContainedOrSame( + root, + realRoot, + 'Configured clone root realpath escaped its normalized path', + ); + assertNotDangerousRoot(realRoot); + assertNotGitNexusInternalRoot(realRoot); + const quarantineRoot = path.join(getAutoSyncWatchDir(), 'quarantine'); + await pruneQuarantineEntries(quarantineRoot); + + return { + root: realRoot, + quarantineRoot, + quarantineRetentionDays: QUARANTINE_RETENTION_DAYS, + }; +} + +export function normalizeConfiguredCloneRoot(localPath: string): string { + const value = localPath.trim(); + if (!value) throw new Error('local_path is required'); + if (!path.isAbsolute(value)) throw new Error('local_path must be an absolute path'); + if (value.split(path.sep).includes('..')) { + throw new Error('local_path must be normalized and must not contain traversal segments'); + } + const resolved = path.resolve(value); + if (resolved !== path.normalize(value)) { + throw new Error('local_path must be normalized and must not contain traversal segments'); + } + return resolved; +} + +export async function quarantineAutoSyncPartial( + targetDir: string, + quarantineRoot: string, +): Promise { + await fs.mkdir(quarantineRoot, { recursive: true, mode: 0o700 }); + const base = path.basename(targetDir); + const stamp = new Date().toISOString().replace(/[:.]/g, '-'); + const destination = path.join( + quarantineRoot, + `auto-sync-${stamp}-${process.pid}-${randomUUID()}-${base}`, + ); + try { + await fs.rename(targetDir, destination); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code !== 'EXDEV') throw err; + await fs.cp(targetDir, destination, { recursive: true }); + await fs.rm(targetDir, { recursive: true, force: true }); + } + await fs.writeFile( + `${destination}.README.txt`, + [ + 'GitNexus auto-sync isolated a partial or unsafe clone result.', + `Created at: ${new Date().toISOString()}`, + `Original path: ${targetDir}`, + `Retention: keep for ${QUARANTINE_RETENTION_DAYS} days unless an operator reviews and removes it earlier.`, + 'Cleanup: verify the original path and remote before manual deletion.', + '', + ].join('\n'), + 'utf-8', + ); + return destination; +} + +async function pruneQuarantineEntries(quarantineRoot: string): Promise { + const cutoff = Date.now() - QUARANTINE_RETENTION_DAYS * 24 * 60 * 60 * 1_000; + // readdir and stat both resolve through a link, so a symlinked quarantine + // root would age-sweep and delete entries somewhere else entirely. + const rootStat = await fs.lstat(quarantineRoot).catch(() => undefined); + if (rootStat?.isSymbolicLink()) { + throw new Error(`Refusing symlinked auto-sync quarantine root: ${quarantineRoot}`); + } + let entries; + try { + entries = await fs.readdir(quarantineRoot); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return; + throw err; + } + const survivors = ( + await Promise.all( + entries + .filter((entry) => entry.startsWith('auto-sync-')) + .map(async (entry) => { + const entryPath = path.join(quarantineRoot, entry); + const stat = await fs.stat(entryPath).catch(() => undefined); + if (stat && stat.mtimeMs < cutoff) { + await fs.rm(entryPath, { recursive: true, force: true }); + return undefined; + } + return entry; + }), + ) + ).filter((entry): entry is string => entry !== undefined); + + // Age alone never bounds a repo that fails on every tick: one partial clone + // per tick stays inside the retention window forever. Keep the newest few per + // repo. Entries that do not match the generated naming scheme (operator + // notes, names from another version) are left to the age sweep alone. + const byRepo = new Map(); + for (const entry of survivors) { + if (entry.endsWith('.README.txt')) continue; + const repo = QUARANTINE_ENTRY_PATTERN.exec(entry)?.[1]; + if (!repo) continue; + const group = byRepo.get(repo) ?? []; + group.push(entry); + byRepo.set(repo, group); + } + await Promise.all( + [...byRepo.values()].flatMap((group) => + group + // The timestamp is the leading fixed-width field, so a descending + // string sort is newest-first. + .sort((a, b) => (a < b ? 1 : a > b ? -1 : 0)) + .slice(QUARANTINE_MAX_ENTRIES_PER_REPO) + .map(async (entry) => { + await fs.rm(path.join(quarantineRoot, entry), { recursive: true, force: true }); + await fs.rm(path.join(quarantineRoot, `${entry}.README.txt`), { force: true }); + }), + ), + ); +} + +function assertNotDangerousRoot(root: string): void { + if (root === path.resolve(getGlobalDir(), 'repos')) return; + if (DANGEROUS_ROOTS.has(root)) throw new Error(`Refusing unsafe auto-sync clone root: ${root}`); + for (const dangerousRoot of DANGEROUS_PARENT_ROOTS) { + const rel = path.relative(dangerousRoot, root); + if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) { + throw new Error(`Refusing unsafe auto-sync clone root under ${dangerousRoot}: ${root}`); + } + } + if (path.parse(root).root === root) + throw new Error(`Refusing filesystem root as clone root: ${root}`); +} + +function assertNotGitNexusInternalRoot(root: string): void { + const gitnexusDir = path.resolve(getGlobalDir()); + const blocked = [ + path.join(gitnexusDir, 'groups'), + path.join(gitnexusDir, 'indexes'), + path.join(gitnexusDir, 'quarantine'), + path.join(getAutoSyncWatchDir(gitnexusDir), 'quarantine'), + ]; + for (const blockedRoot of blocked) { + const rel = path.relative(blockedRoot, root); + if (!rel || (!rel.startsWith('..') && !path.isAbsolute(rel))) { + throw new Error(`Refusing GitNexus internal directory as auto-sync clone root: ${root}`); + } + } +} + +async function assertNoSymlinkPath(root: string): Promise { + const parsed = path.parse(root); + let current = parsed.root; + const parts = root.slice(parsed.root.length).split(path.sep).filter(Boolean); + for (const part of parts) { + current = path.join(current, part); + let stat; + try { + stat = await fs.lstat(current); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') break; + throw err; + } + if (stat.isSymbolicLink()) + throw new Error(`Refusing symlink in auto-sync clone root path: ${current}`); + } +} + +export async function assertDirectoryOwnerAndPermissions(root: string): Promise { + const stat = await fs.stat(root); + if (!stat.isDirectory()) throw new Error(`auto-sync clone root is not a directory: ${root}`); + // POSIX uid/mode have no meaning on Windows, and this runs on every tick for + // every project, so throwing here failed 100% of repos forever while `watch + // status` still read `running`. Skip the ownership assertions rather than the + // whole feature: the caller's other guards — dangerous-root rejection + // (including the Windows system roots), symlink refusal, realpath containment + // and the GitNexus-internal-root check — all still apply, and managed git runs + // with `core.hooksPath` pinned to the null device. + if (process.platform === 'win32') return; + if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) { + throw new Error(`auto-sync clone root is owned by uid ${stat.uid}, not current process uid`); + } + const mode = stat.mode & 0o777; + const groupWritable = (mode & 0o020) !== 0; + const worldWritable = (mode & 0o002) !== 0; + if (worldWritable) { + throw new Error(`Refusing world-writable auto-sync clone root: ${root}`); + } + if (groupWritable) { + throw new Error(`Refusing group-writable auto-sync clone root: ${root}`); + } +} + +function assertContainedOrSame(root: string, child: string, message: string): void { + const rel = path.relative(root, child); + if (rel.startsWith('..') || path.isAbsolute(rel)) throw new Error(message); +} diff --git a/gitnexus/src/core/auto-sync/repo.ts b/gitnexus/src/core/auto-sync/repo.ts new file mode 100644 index 000000000..ed8e1731d --- /dev/null +++ b/gitnexus/src/core/auto-sync/repo.ts @@ -0,0 +1,7 @@ +import { extractRepoName } from '../../server/git-clone.js'; +import { validateAutoSyncRemoteUrl } from './config.js'; + +export function extractRepoNameFromRemoteUrl(remoteUrl: string): string { + validateAutoSyncRemoteUrl(remoteUrl); + return extractRepoName(remoteUrl); +} diff --git a/gitnexus/src/core/auto-sync/runner.ts b/gitnexus/src/core/auto-sync/runner.ts new file mode 100644 index 000000000..ed5f7afd7 --- /dev/null +++ b/gitnexus/src/core/auto-sync/runner.ts @@ -0,0 +1,561 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { loadGroupConfig } from '../group/config-parser.js'; +import { getDefaultGitnexusDir, getGroupDir } from '../group/storage.js'; +import { syncGroup } from '../group/sync.js'; +import { registerRepo, resolveBranchPlacement, type RepoMeta } from '../../storage/repo-manager.js'; +import { extractRepoNameFromRemoteUrl } from './repo.js'; +import { cloneOrPull, runGit } from '../../server/git-clone.js'; +import { resolveConfiguredCloneRoot } from './path-security.js'; +import { + buildStateKey, + loadAutoSyncState, + saveAutoSyncState, + shouldAnalyzeCommit, + writeProjectCommitInfo, + type AutoSyncAnalyzeStatus, + type AutoSyncCommitStateEntry, + type ProjectCommitInfoEntry, +} from './state.js'; +import type { AutoSyncConfig, AutoSyncProjectConfig } from './config.js'; +import { validateAutoSyncRemoteUrl } from './config.js'; +import { runAutoSyncAnalysis, type AutoSyncAnalysisRunner } from './analysis-worker-launch.js'; + +export interface AutoSyncLogger { + info(message: string): void; + warn(message: string): void; + error(message: string): void; +} + +export interface AutoSyncRunDeps { + cloneOrPull: typeof cloneOrPull; + getCurrentBranch: (repoPath: string, timeoutMs: number) => Promise; + getCurrentCommit: (repoPath: string, timeoutMs: number) => Promise; + runAnalysis: AutoSyncAnalysisRunner; + registerRepo: typeof registerRepo; + resolveBranchPlacement: typeof resolveBranchPlacement; + loadState: typeof loadAutoSyncState; + saveState: typeof saveAutoSyncState; + writeCommitInfo: typeof writeProjectCommitInfo; + addRepoToGroup: typeof addRepoToGroup; + syncGroupByName: typeof syncGroupByName; + resolveCloneRoot: typeof resolveConfiguredCloneRoot; + getAvailableMemoryGB: () => number; +} + +export interface AutoSyncRunResult { + synced: number; + analyzed: number; + skippedAnalysis: number; + failed: number; +} + +const _require = createRequire(import.meta.url); +const yaml = _require('js-yaml') as typeof import('js-yaml'); + +const DEFAULT_LOGGER: AutoSyncLogger = { + info: (message) => process.stderr.write(`${message}\n`), + warn: (message) => process.stderr.write(`${message}\n`), + error: (message) => process.stderr.write(`${message}\n`), +}; + +const DEFAULT_DEPS: AutoSyncRunDeps = { + cloneOrPull, + getCurrentBranch: async (repoPath, timeoutMs) => { + const branch = (await runGit(['branch', '--show-current'], repoPath, { timeoutMs })).trim(); + return branch || undefined; + }, + getCurrentCommit: async (repoPath, timeoutMs) => + (await runGit(['rev-parse', 'HEAD'], repoPath, { timeoutMs })).trim(), + runAnalysis: runAutoSyncAnalysis, + registerRepo, + resolveBranchPlacement, + loadState: loadAutoSyncState, + saveState: saveAutoSyncState, + writeCommitInfo: writeProjectCommitInfo, + addRepoToGroup, + syncGroupByName, + resolveCloneRoot: resolveConfiguredCloneRoot, + getAvailableMemoryGB: () => Math.floor(process.availableMemory?.() ?? 0) / 1024 / 1024 / 1024, +}; + +export async function runAutoSyncOnce( + config: AutoSyncConfig, + options: { + deps?: Partial; + logger?: AutoSyncLogger; + now?: () => Date; + signal?: AbortSignal; + onAnalysisCancellationRequested?: () => void; + } = {}, +): Promise { + const deps = { ...DEFAULT_DEPS, ...options.deps }; + const logger = options.logger ?? DEFAULT_LOGGER; + const now = options.now ?? (() => new Date()); + throwIfAborted(options.signal); + const state = await deps.loadState(); + throwIfAborted(options.signal); + const groupsToSync = new Set(); + const groupStateKeys = new Map(); + const result: AutoSyncRunResult = { synced: 0, analyzed: 0, skippedAnalysis: 0, failed: 0 }; + const commitInfoEntries: ProjectCommitInfoEntry[] = []; + const actualConcurrency = resolveActualConcurrency( + config.maxConcurrency, + deps.getAvailableMemoryGB(), + ); + logger.info( + `[auto-sync] Starting sync loop with max_concurrency=${actualConcurrency} analyze_failure_threshold=${config.analyzeFailureThreshold}.`, + ); + + const workItems = await buildWorkItems(config, deps); + // What will actually run at once. One repo means one worker, so the common + // single-project case still hands that worker the whole machine budget. + const analysisParallelism = Math.max(1, Math.min(actualConcurrency, workItems.length)); + const repoResults = await mapWithConcurrency( + workItems, + actualConcurrency, + options.signal, + async (item) => { + const lastSyncTime = now().toISOString(); + try { + throwIfAborted(options.signal); + if (!item.cloneRoot || !item.repoName || !item.targetDir) { + throw new Error(item.error ?? 'Invalid auto-sync work item'); + } + const repoName = item.repoName; + const targetDir = item.targetDir; + const syncResult = await syncFirstAvailableBranch({ + item, + repoName, + targetDir, + timeoutMs: config.repoGitTimeoutMs, + deps, + logger, + }); + throwIfAborted(options.signal); + if (syncResult.ok === false) { + logger.error( + `[auto-sync] Repository sync failed for ${item.remoteUrl}; no configured branch could be pulled: ${syncResult.message}`, + ); + return { + kind: 'failed' as const, + project: item.project, + remoteUrl: item.remoteUrl, + targetDir, + branch: item.project.branches[0], + status: syncResult.status, + analyzeConsecutiveFailures: 0, + lastSyncTime, + }; + } + + const currentBranch = syncResult.branch; + + const currentCommit = await deps.getCurrentCommit(targetDir, config.repoGitTimeoutMs); + const stateKey = buildStateKey(targetDir, currentBranch); + const previous = state[stateKey]; + let analyzeStatus: AutoSyncAnalyzeStatus = 'skipped'; + let analyzedCommitId = previous?.analyzedCommitId; + let analyzeConsecutiveFailures = previous?.analyzeConsecutiveFailures ?? 0; + let lastAnalyzeError = previous?.lastAnalyzeError; + const groupSyncPending = previous?.groupSyncPending === true; + let stats: RepoMeta['stats'] | undefined; + + if (previous && previous.codeCommitId !== currentCommit) { + analyzeConsecutiveFailures = 0; + lastAnalyzeError = undefined; + } + + if (analyzeConsecutiveFailures >= config.analyzeFailureThreshold) { + analyzeStatus = 'threshold_skipped'; + logger.error( + `[auto-sync] Skip analysis for ${targetDir}; analyze consecutive failures ${analyzeConsecutiveFailures}/${config.analyzeFailureThreshold} reached threshold. Fix the repository or clear auto-sync state before retrying.`, + ); + } else if ( + shouldAnalyzeCommit({ + currentCommit, + previousAnalyzedCommit: previous?.analyzedCommitId, + previousStatus: previous?.lastAnalyzeStatus, + }) + ) { + try { + const analysis = await deps.runAnalysis( + targetDir, + { branch: currentBranch, skipAgentsMd: true, skipSkills: true }, + config.analyzeTimeoutMs, + options.signal, + options.onAnalysisCancellationRequested, + analysisParallelism, + ); + throwIfAborted(options.signal); + stats = analysis.stats; + analyzeStatus = 'success'; + analyzedCommitId = currentCommit; + analyzeConsecutiveFailures = 0; + lastAnalyzeError = undefined; + } catch (err: unknown) { + if (options.signal?.aborted) throw err; + analyzeStatus = 'failed'; + analyzeConsecutiveFailures += 1; + lastAnalyzeError = shortErrorMessage(err); + logger.error( + `[auto-sync] Analysis failed for ${targetDir}; consecutive failures ${analyzeConsecutiveFailures}/${config.analyzeFailureThreshold}: ${lastAnalyzeError}`, + ); + } + } else { + logger.info(`[auto-sync] Skip analysis for ${targetDir}; commit unchanged.`); + } + throwIfAborted(options.signal); + + return { + kind: 'synced' as const, + project: item.project, + repoName, + remoteUrl: item.remoteUrl, + targetDir, + branch: currentBranch, + currentCommit, + analyzedCommitId, + analyzeStatus, + analyzeConsecutiveFailures, + lastAnalyzeError, + groupSyncPending, + stats, + stateKey, + lastSyncTime, + }; + } catch (err: unknown) { + if (options.signal?.aborted) throw err; + logger.error( + `[auto-sync] Repository sync failed for ${item.remoteUrl}: ${(err as Error).message}`, + ); + return { + kind: 'failed' as const, + project: item.project, + remoteUrl: item.remoteUrl, + targetDir: item.targetDir ?? '', + status: 'sync_failed' as const, + lastSyncTime, + }; + } + }, + ); + + for (const repoResult of repoResults) { + if (repoResult.kind === 'failed') { + result.failed += 1; + commitInfoEntries.push({ + remoteUrl: repoResult.remoteUrl, + localPath: repoResult.targetDir, + branch: repoResult.branch, + status: repoResult.status, + lastSyncTime: repoResult.lastSyncTime, + }); + continue; + } + + result.synced += 1; + let analyzeStatus = repoResult.analyzeStatus; + let analyzeConsecutiveFailures = repoResult.analyzeConsecutiveFailures; + let lastAnalyzeError = repoResult.lastAnalyzeError; + let analyzedCommitId = repoResult.analyzedCommitId; + if (analyzeStatus === 'success') { + const meta: RepoMeta = { + repoPath: repoResult.targetDir, + lastCommit: repoResult.currentCommit, + indexedAt: repoResult.lastSyncTime, + stats: repoResult.stats!, + branch: repoResult.branch, + remoteUrl: repoResult.remoteUrl, + }; + try { + // Reproduce the placement the analyze worker already made. Registering + // without a branch always takes the primary/flat arm, which relabels a + // pinned branch entry with whatever this tick happened to sync — visible + // on the documented branch-fallback path. + const placement = await deps.resolveBranchPlacement( + repoResult.targetDir, + repoResult.branch, + ); + await deps.registerRepo(repoResult.targetDir, meta, { + name: getAutoSyncRepoIdentity(repoResult.remoteUrl), + // Omitted rather than passed as undefined, so a primary index is + // registered with the same option shape it had before this branch. + ...(placement.branch ? { branch: placement.branch } : {}), + }); + result.analyzed += 1; + } catch (err: unknown) { + analyzeStatus = 'failed'; + analyzedCommitId = undefined; + analyzeConsecutiveFailures += 1; + lastAnalyzeError = `Repository registration failed: ${shortErrorMessage(err)}`; + result.failed += 1; + logger.error(`[auto-sync] ${lastAnalyzeError}`); + } + } else if (analyzeStatus === 'failed') { + result.failed += 1; + } else { + result.skippedAnalysis += 1; + } + + const stateEntry: AutoSyncCommitStateEntry = { + codeCommitId: repoResult.currentCommit, + analyzedCommitId, + lastAnalyzeStatus: analyzeStatus, + analyzeConsecutiveFailures, + lastAnalyzeError, + groupSyncPending: repoResult.groupSyncPending, + lastSyncTime: repoResult.lastSyncTime, + }; + state[repoResult.stateKey] = stateEntry; + + commitInfoEntries.push({ + remoteUrl: repoResult.remoteUrl, + localPath: repoResult.targetDir, + branch: repoResult.branch, + codeCommitId: repoResult.currentCommit, + analyzedCommitId, + status: analyzeStatus, + analyzeConsecutiveFailures, + analyzeFailureThreshold: config.analyzeFailureThreshold, + lastAnalyzeError, + lastSyncTime: repoResult.lastSyncTime, + }); + + if (repoResult.project.groupName) { + let groupMembershipOk = false; + let membershipAdded = false; + try { + membershipAdded = await deps.addRepoToGroup( + repoResult.project, + getAutoSyncRepoIdentity(repoResult.remoteUrl), + getAutoSyncRepoIdentity(repoResult.remoteUrl), + ); + groupMembershipOk = true; + } catch (err: unknown) { + result.failed += 1; + logger.error( + `[auto-sync] Group update failed for ${repoResult.project.groupName}: ${(err as Error).message}`, + ); + } + if ( + groupMembershipOk && + (analyzeStatus === 'success' || + (membershipAdded && analyzeStatus === 'skipped') || + (analyzeStatus === 'skipped' && repoResult.groupSyncPending)) + ) { + const groupName = repoResult.project.groupName; + groupsToSync.add(groupName); + const keys = groupStateKeys.get(groupName) ?? []; + keys.push(repoResult.stateKey); + groupStateKeys.set(groupName, keys); + } + } + } + + await deps.saveState(state); + await deps.writeCommitInfo(commitInfoEntries); + let groupStateChanged = false; + for (const groupName of groupsToSync) { + try { + await deps.syncGroupByName(groupName); + for (const stateKey of groupStateKeys.get(groupName) ?? []) { + if (state[stateKey].groupSyncPending) { + state[stateKey].groupSyncPending = false; + groupStateChanged = true; + } + } + } catch (err: unknown) { + result.failed += 1; + for (const stateKey of groupStateKeys.get(groupName) ?? []) { + if (!state[stateKey].groupSyncPending) { + state[stateKey].groupSyncPending = true; + groupStateChanged = true; + } + } + logger.error(`[auto-sync] Group sync failed for ${groupName}: ${(err as Error).message}`); + } + } + if (groupStateChanged) await deps.saveState(state); + return result; +} + +function shortErrorMessage(err: unknown): string { + const message = err instanceof Error ? err.message : String(err); + return message.replace(/\s+/g, ' ').slice(0, 240); +} + +export function getConfiguredRepoPath( + project: Pick, + repoName: string, + remoteUrl?: string, +): string { + if (!remoteUrl) return path.resolve(project.localPath, repoName); + const identity = getAutoSyncRepoIdentity(remoteUrl); + return path.resolve(project.localPath, ...identity.split('/').slice(0, -1), repoName); +} + +export async function addRepoToGroup( + project: Pick, + groupPath: string, + registryName = groupPath, +): Promise { + if (!project.groupName) return false; + const groupDir = getGroupDir(getDefaultGitnexusDir(), project.groupName); + const config = await loadGroupConfig(groupDir); + if (config.repos[groupPath] === registryName) return false; + if (config.repos[groupPath] !== undefined) { + throw new Error(`group path ${groupPath} is already mapped to ${config.repos[groupPath]}`); + } + config.repos[groupPath] = registryName; + await writeGroupConfigAtomic(path.join(groupDir, 'group.yaml'), config); + return true; +} + +export function getAutoSyncRepoIdentity(remoteUrl: string): string { + validateAutoSyncRemoteUrl(remoteUrl); + const [, host, remotePath] = /^git@([^:\s/]+):([^\s]+)$/.exec(remoteUrl.trim())!; + return `${host.toLowerCase()}/${remotePath.replace(/\.git$/i, '')}`; +} + +export async function syncGroupByName(groupName: string): Promise { + const groupDir = getGroupDir(getDefaultGitnexusDir(), groupName); + const config = await loadGroupConfig(groupDir); + await syncGroup(config, { groupDir }); +} + +async function writeGroupConfigAtomic(filePath: string, config: unknown): Promise { + const tmpPath = `${filePath}.tmp.${process.pid}.${Date.now()}`; + await fs.writeFile(tmpPath, yaml.dump(config), 'utf-8'); + await fs.rename(tmpPath, filePath); +} + +export function resolveActualConcurrency(configured: number, availableMemoryGB: number): number { + const memoryLimit = Math.max(1, Math.floor(availableMemoryGB / 2)); + return Math.max(1, Math.min(configured, memoryLimit)); +} + +async function buildWorkItems( + config: AutoSyncConfig, + deps: AutoSyncRunDeps, +): Promise { + const items: AutoSyncWorkItem[] = []; + const targetOwners = new Map(); + for (const project of config.projects) { + let cloneRoot: AutoSyncWorkItem['cloneRoot']; + try { + cloneRoot = await deps.resolveCloneRoot(project.localPath); + } catch (err: unknown) { + for (const remoteUrl of project.remoteUrls) { + items.push({ project, remoteUrl, error: shortErrorMessage(err) }); + } + continue; + } + for (const remoteUrl of project.remoteUrls) { + try { + const repoName = extractRepoNameFromRemoteUrl(remoteUrl); + const targetDir = getConfiguredRepoPath({ localPath: cloneRoot.root }, repoName, remoteUrl); + const previous = targetOwners.get(targetDir); + if (previous !== undefined) { + throw new Error( + `Duplicate auto-sync targetDir ${targetDir} for ${previous} and ${remoteUrl}`, + ); + } + targetOwners.set(targetDir, remoteUrl); + items.push({ project, remoteUrl, cloneRoot, repoName, targetDir }); + } catch (err: unknown) { + items.push({ project, remoteUrl, error: shortErrorMessage(err) }); + } + } + } + return items; +} + +async function mapWithConcurrency( + items: T[], + concurrency: number, + signal: AbortSignal | undefined, + worker: (item: T) => Promise, +): Promise { + const results: R[] = new Array(items.length); + let nextIndex = 0; + const runners = Array.from({ length: Math.min(concurrency, items.length) }, async () => { + while (nextIndex < items.length) { + throwIfAborted(signal); + const currentIndex = nextIndex; + nextIndex += 1; + results[currentIndex] = await worker(items[currentIndex]); + throwIfAborted(signal); + } + }); + // Settle every runner before surfacing a failure. Promise.all rejects on the + // first error while siblings are still inside a clone or waiting on an + // analyze fork, and the caller treats that rejection as "the run is over" — + // it releases the watch mutex and exits, orphaning those children. Each + // runner already refuses new work at the abort check above, so waiting here + // costs nothing on the cancel path. + const settlements = await Promise.allSettled(runners); + const failure = settlements.find((s) => s.status === 'rejected'); + if (failure) throw (failure as PromiseRejectedResult).reason; + return results; +} + +function throwIfAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) throw new Error('Auto-sync run cancelled.'); +} + +interface AutoSyncWorkItem { + project: AutoSyncProjectConfig; + remoteUrl: string; + cloneRoot?: Awaited>; + repoName?: string; + targetDir?: string; + error?: string; +} + +async function syncFirstAvailableBranch(input: { + item: AutoSyncWorkItem; + repoName: string; + targetDir: string; + timeoutMs: number; + deps: AutoSyncRunDeps; + logger: AutoSyncLogger; +}): Promise< + | { ok: true; branch: string } + | { ok: false; status: 'branch_unavailable' | 'sync_timeout'; message: string } +> { + const failures: string[] = []; + let sawTimeout = false; + for (const branch of input.item.project.branches) { + try { + await input.deps.cloneOrPull(input.item.remoteUrl, input.targetDir, undefined, { + allowedCloneRoot: input.item.cloneRoot!.root, + expectedRepoName: input.repoName, + quarantineRoot: input.item.cloneRoot!.quarantineRoot, + allowAutoSyncSsh: true, + timeoutMs: input.timeoutMs, + branch, + overwriteLocalChanges: input.item.project.overwriteLocalChanges, + }); + const currentBranch = await input.deps.getCurrentBranch(input.targetDir, input.timeoutMs); + if (currentBranch === branch) return { ok: true, branch }; + failures.push(`${branch}: checked out ${currentBranch ?? ''}`); + input.logger.warn( + `[auto-sync] Branch ${branch} for ${input.item.remoteUrl} synced but current branch is ${currentBranch ?? ''}; trying next branch.`, + ); + } catch (err: unknown) { + const message = (err as Error).message; + if (message.includes('timed out')) sawTimeout = true; + failures.push(`${branch}: ${message}`); + input.logger.warn( + `[auto-sync] Branch ${branch} unavailable for ${input.item.remoteUrl}: ${message}`, + ); + } + } + return { + ok: false, + status: sawTimeout ? 'sync_timeout' : 'branch_unavailable', + message: failures.join('; '), + }; +} diff --git a/gitnexus/src/core/auto-sync/starter.ts b/gitnexus/src/core/auto-sync/starter.ts new file mode 100644 index 000000000..624e092ec --- /dev/null +++ b/gitnexus/src/core/auto-sync/starter.ts @@ -0,0 +1,643 @@ +import fs from 'node:fs/promises'; +import crypto from 'node:crypto'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { acquireFileLock, FileLockBusyError } from '../../storage/file-lock.js'; +import { getGlobalDir } from '../../storage/repo-manager.js'; +import { isProcessAlive, readProcessStartTime } from '../../utils/process-identity.js'; +import { loadAutoSyncConfig } from './config.js'; +import { runAutoSyncOnce } from './runner.js'; +import { getAutoSyncMutexPath, getAutoSyncWatchDir } from './state.js'; + +export interface AutoSyncStartHandle { + stop(): Promise; +} + +export type WatchStatusState = + | 'running' + | 'cancelling' + | 'stopping' + | 'stopped' + | 'stale' + | 'error'; +export type AutoSyncWatchStopResult = 'stopped' | 'not_running' | 'refused' | 'timeout'; + +export interface WatchStatusRecord { + state: WatchStatusState; + pid?: number; + ownerId?: string; + configPath?: string; + message?: string; + updatedAt: string; +} + +export interface WatchOwnerRecord { + pid: number; + ownerId: string; + processStartTime: string; + createdAt: string; +} + +interface WatchStopRequestRecord { + pid: number; + ownerId: string; + processStartTime: string; + requestedAt: string; +} + +const WATCH_STOP_POLL_MS = 250; + +export interface AutoSyncWatchPaths { + pidPath: string; + mutexPath: string; + ownerPath: string; + statusPath: string; +} + +export interface AutoSyncWatchControlDeps { + isProcessAlive(pid: number): boolean; + readProcessCommand(pid: number): string | undefined; + readProcessStartTime(pid: number): string | undefined; + sleep(ms: number): Promise; +} + +export function getAutoSyncWatchPaths(gitnexusDir = getGlobalDir()): AutoSyncWatchPaths { + const watchDir = getAutoSyncWatchDir(gitnexusDir); + return { + pidPath: path.join(watchDir, 'watch.pid'), + mutexPath: getAutoSyncMutexPath(gitnexusDir), + ownerPath: path.join(watchDir, 'watch.owner.json'), + statusPath: path.join(watchDir, 'watch.status.json'), + }; +} + +export async function startAutoSyncWatch( + options: { + setIntervalFn?: typeof setInterval; + clearIntervalFn?: typeof clearInterval; + runOnce?: typeof runAutoSyncOnce; + stderr?: Pick; + keepAlive?: boolean; + paths?: AutoSyncWatchPaths; + deps?: Partial; + } = {}, +): Promise { + const stderr = options.stderr ?? process.stderr; + const paths = options.paths ?? getAutoSyncWatchPaths(); + const deps = resolveWatchDeps(options.deps); + const ownerId = crypto.randomUUID(); + const processStartTime = deps.readProcessStartTime(process.pid); + if (!processStartTime) { + stderr.write('[auto-sync] Unable to verify the watch process start time.\n'); + return null; + } + await fs.mkdir(path.dirname(paths.pidPath), { recursive: true }); + const releaseLock = await acquireWatchLock(paths, deps, stderr, processStartTime); + if (!releaseLock) return null; + + try { + await writeWatchOwner(paths, { + pid: process.pid, + ownerId, + processStartTime, + createdAt: new Date().toISOString(), + }); + await writeAtomicText(paths.pidPath, `${process.pid}\n`); + + const loaded = await loadAutoSyncConfig(); + if (loaded.ok === false) { + stderr.write(`${loaded.message}\n`); + await writeWatchStatus(paths, { + state: 'error', + pid: process.pid, + ownerId, + message: loaded.message, + updatedAt: new Date().toISOString(), + }); + await cleanupWatchFiles(paths, ownerId, releaseLock); + return null; + } + await writeWatchStatus(paths, { + state: 'running', + pid: process.pid, + ownerId, + configPath: loaded.config.configPath, + updatedAt: new Date().toISOString(), + }); + + const runOnce = options.runOnce ?? runAutoSyncOnce; + const setIntervalFn = options.setIntervalFn ?? setInterval; + const clearIntervalFn = options.clearIntervalFn ?? clearInterval; + let activeRun: Promise | undefined; + let activeAbortController: AbortController | undefined; + let stopping = false; + let statusWrite = Promise.resolve(); + const updateStatus = (state: WatchStatusState, message?: string) => { + const write = statusWrite.then(() => + writeWatchStatus(paths, { + state, + pid: process.pid, + ownerId, + configPath: loaded.config.configPath, + message, + updatedAt: new Date().toISOString(), + }), + ); + statusWrite = write.catch(() => {}); + return write; + }; + const reportStatusWriteFailure = (error: unknown) => { + stderr.write(`[auto-sync] Failed to publish watch status: ${(error as Error).message}\n`); + }; + const runSafely = () => { + if (stopping) return; + if (activeRun) { + stderr.write('[auto-sync] Previous run is still active; skipping overlapping run.\n'); + return; + } + const startedAt = new Date(); + stderr.write(`[auto-sync] Watch loop started at ${startedAt.toISOString()}.\n`); + const abortController = new AbortController(); + const run = runOnce(loaded.config, { + signal: abortController.signal, + onAnalysisCancellationRequested: () => { + if (!stopping) { + void updateStatus( + 'cancelling', + 'Analysis cancellation requested; waiting for the worker to reach a safe shutdown point.', + ).catch(reportStatusWriteFailure); + } + }, + }) + .then((result) => { + stderr.write( + `[auto-sync] Watch loop finished: synced=${result.synced} analyzed=${result.analyzed} skipped=${result.skippedAnalysis} failed=${result.failed}.\n`, + ); + }) + .catch((err: unknown) => { + stderr.write(`[auto-sync] Scheduled run failed: ${(err as Error).message}\n`); + stderr.write('[auto-sync] Watch loop finished: failed.\n'); + }) + .finally(async () => { + if (activeRun === run) { + activeRun = undefined; + activeAbortController = undefined; + } + if (!stopping) { + await updateStatus('running').catch(reportStatusWriteFailure); + } + }); + activeRun = run; + activeAbortController = abortController; + }; + + let stopPromise: Promise | undefined; + const stop = () => + (stopPromise ??= (async () => { + stopping = true; + clearIntervalFn(timer); + clearIntervalFn(controlTimer); + activeAbortController?.abort(); + try { + await updateStatus('stopping'); + await activeRun?.catch(() => {}); + await updateStatus('stopped'); + } finally { + await cleanupWatchFiles(paths, ownerId, releaseLock); + } + })()); + const checkStopRequest = async () => { + const request = await readStopRequest(stopRequestPath(paths, ownerId)); + if ( + request?.pid === process.pid && + request.ownerId === ownerId && + request.processStartTime === processStartTime + ) { + void stop().catch((error: unknown) => { + stderr.write(`[auto-sync] Failed to stop watch: ${(error as Error).message}\n`); + }); + } + }; + + runSafely(); + const controlTimer = setIntervalFn(() => void checkStopRequest(), WATCH_STOP_POLL_MS); + const timer = setIntervalFn(runSafely, loaded.config.syncIntervalMinutes * 60_000); + if (options.keepAlive === false) { + controlTimer.unref?.(); + timer.unref?.(); + } + return { stop }; + } catch (error) { + await cleanupWatchFiles(paths, ownerId, releaseLock).catch(() => {}); + throw error; + } +} + +async function acquireWatchLock( + paths: AutoSyncWatchPaths, + deps: AutoSyncWatchControlDeps, + stderr: Pick, + processStartTime: string, +): Promise<(() => Promise) | null> { + try { + return await acquireFileLock(paths.mutexPath, { + pid: process.pid, + processStartTime, + isProcessAlive: deps.isProcessAlive, + readProcessStartTime: deps.readProcessStartTime, + }); + } catch (err: unknown) { + if (!(err instanceof FileLockBusyError)) throw err; + } + + const owner = await readOwnerFile(paths.ownerPath); + if (!owner) { + stderr.write( + `[auto-sync] Watch mutex is held but owner metadata is not ready or invalid. Confirm no watch process is running, then remove ${paths.mutexPath}.\n`, + ); + return null; + } + if (!deps.isProcessAlive(owner.pid)) { + stderr.write( + `[auto-sync] Watch mutex remains after owner pid ${owner.pid} exited. Confirm no watch process is running, then remove ${paths.mutexPath}.\n`, + ); + return null; + } + const reason = getWatchProcessIdentityError(owner, deps); + if (reason) { + stderr.write(`[auto-sync] Refusing to trust existing watch pid ${owner.pid}; ${reason}.\n`); + return null; + } + stderr.write(`[auto-sync] Watch is already running with pid ${owner.pid}.\n`); + return null; +} + +export async function stopAutoSyncWatch( + options: { + paths?: AutoSyncWatchPaths; + stderr?: Pick; + deps?: Partial; + timeoutMs?: number; + pollMs?: number; + } = {}, +): Promise { + const stderr = options.stderr ?? process.stderr; + const paths = options.paths ?? getAutoSyncWatchPaths(); + const deps = resolveWatchDeps(options.deps); + const timeoutMs = options.timeoutMs ?? 10_000; + const pollMs = options.pollMs ?? 100; + const pid = await readPid(paths.pidPath); + if (!pid) { + const owner = await readOwnerFile(paths.ownerPath); + if (owner && deps.isProcessAlive(owner.pid)) { + stderr.write( + `[auto-sync] Watch appears to be starting with pid ${owner.pid}; pid file is not ready.\n`, + ); + return 'refused'; + } + if (owner || (await fileExists(paths.mutexPath))) { + stderr.write( + `[auto-sync] Watch ownership is stale or incomplete. Confirm no watch process is running, then remove ${paths.mutexPath}.\n`, + ); + return 'refused'; + } + stderr.write('[auto-sync] Watch is not running.\n'); + return 'not_running'; + } + if (!deps.isProcessAlive(pid)) { + stderr.write( + `[auto-sync] Watch pid ${pid} is stale. Confirm no watch process is running, then remove ${paths.mutexPath}.\n`, + ); + return 'refused'; + } + + const owner = await readVerifiedWatchOwner(paths, pid, deps); + if (owner.ok === false) { + stderr.write(`[auto-sync] Refusing to stop pid ${pid}; ${owner.reason}.\n`); + return 'refused'; + } + + const currentPid = await readPid(paths.pidPath); + const currentOwner = await readVerifiedWatchOwner(paths, pid, deps); + if ( + currentPid !== pid || + currentOwner.ok === false || + currentOwner.owner.ownerId !== owner.owner.ownerId + ) { + stderr.write(`[auto-sync] Refusing to stop pid ${pid}; watch ownership changed.\n`); + return 'refused'; + } + + await writeAtomicText( + stopRequestPath(paths, owner.owner.ownerId), + `${JSON.stringify({ + pid, + ownerId: owner.owner.ownerId, + processStartTime: owner.owner.processStartTime, + requestedAt: new Date().toISOString(), + } satisfies WatchStopRequestRecord)}\n`, + ); + stderr.write(`[auto-sync] Stop requested for watch pid ${pid}.\n`); + const stopped = await waitForProcessExit(pid, { + deps, + timeoutMs, + pollMs, + processStartTime: owner.owner.processStartTime, + }); + if (!stopped) { + stderr.write(`[auto-sync] Watch pid ${pid} did not exit within ${timeoutMs}ms.\n`); + return 'timeout'; + } + return 'stopped'; +} + +export async function readAutoSyncWatchStatus( + paths = getAutoSyncWatchPaths(), + deps: Partial = {}, +): Promise { + const resolvedDeps = resolveWatchDeps(deps); + const pid = await readPid(paths.pidPath); + const stored = await readStatusFile(paths.statusPath); + const updatedAt = stored?.updatedAt ?? new Date().toISOString(); + if (pid && !resolvedDeps.isProcessAlive(pid)) { + return { + ...stored, + state: 'stale', + pid, + message: 'pid file exists but process is not running', + updatedAt, + }; + } + if (pid) { + const owner = await readVerifiedWatchOwner(paths, pid, resolvedDeps); + if (owner.ok === false) { + return { + ...stored, + state: 'error', + pid, + message: owner.reason, + updatedAt, + }; + } + if (stored?.state === 'error') { + return { + ...stored, + pid, + ownerId: owner.owner.ownerId, + updatedAt, + }; + } + return { + ...stored, + state: + stored?.state === 'cancelling' || stored?.state === 'stopping' ? stored.state : 'running', + pid, + ownerId: owner.owner.ownerId, + updatedAt, + }; + } + return stored ?? { state: 'stopped', updatedAt }; +} + +function isSafeWatchOwnerId(ownerId: string): boolean { + return ( + ownerId === path.basename(ownerId) && + !ownerId.includes('..') && + !ownerId.includes('/') && + !ownerId.includes('\\') + ); +} + +async function readOwnerFile(ownerPath: string): Promise { + try { + const raw = await fs.readFile(ownerPath, 'utf-8'); + const parsed = JSON.parse(raw) as WatchOwnerRecord; + if ( + parsed && + typeof parsed === 'object' && + Number.isInteger(parsed.pid) && + parsed.pid > 0 && + typeof parsed.ownerId === 'string' && + parsed.ownerId && + isSafeWatchOwnerId(parsed.ownerId) && + typeof parsed.processStartTime === 'string' && + parsed.processStartTime + ) { + return parsed; + } + return undefined; + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + return undefined; + } +} + +async function readVerifiedWatchOwner( + paths: AutoSyncWatchPaths, + pid: number, + deps: AutoSyncWatchControlDeps, +): Promise<{ ok: true; owner: WatchOwnerRecord } | { ok: false; reason: string }> { + const [status, owner] = await Promise.all([ + readStatusFile(paths.statusPath), + readOwnerFile(paths.ownerPath), + ]); + if (!owner) return { ok: false, reason: 'watch owner is missing or invalid' }; + if (!status) return { ok: false, reason: 'watch status is missing or invalid' }; + if (owner.pid !== pid) return { ok: false, reason: 'watch owner pid does not match pid file' }; + if (status.pid !== pid) return { ok: false, reason: 'watch status pid does not match pid file' }; + if (!status.ownerId || status.ownerId !== owner.ownerId) { + return { ok: false, reason: 'watch status owner does not match watch owner' }; + } + const identityError = getWatchProcessIdentityError(owner, deps); + if (identityError) return { ok: false, reason: identityError }; + return { ok: true, owner }; +} + +function getWatchProcessIdentityError( + owner: WatchOwnerRecord, + deps: AutoSyncWatchControlDeps, +): string | undefined { + const processStartTime = deps.readProcessStartTime(owner.pid); + if (!processStartTime) return 'unable to verify process start time'; + if (processStartTime !== owner.processStartTime) return 'pid belongs to a different process'; + const command = deps.readProcessCommand(owner.pid); + if (!command) return 'unable to verify process command'; + if ( + !/(?:^|\s)(?:watch|auto-sync)(?:\s|$)/.test(command) || + !/(?:gitnexus|[\\/]cli[\\/]index\.(?:ts|[cm]?js))/.test(command) + ) { + return 'pid command is not a GitNexus auto-sync process'; + } + return undefined; +} + +async function waitForProcessExit( + pid: number, + options: { + deps: AutoSyncWatchControlDeps; + timeoutMs: number; + pollMs: number; + processStartTime?: string; + }, +): Promise { + // A bare liveness poll cannot tell "still running" from "exited, and the OS + // handed the pid to something else" — so a reused pid would keep us waiting + // on an unrelated process and then report the watch stopped once THAT exits. + // The start time identifies the process behind the number. + const isOriginalProcessAlive = () => { + if (!options.deps.isProcessAlive(pid)) return false; + if (!options.processStartTime) return true; + const startTime = options.deps.readProcessStartTime(pid); + return startTime === undefined || startTime === options.processStartTime; + }; + const deadline = Date.now() + options.timeoutMs; + while (Date.now() < deadline) { + if (!isOriginalProcessAlive()) return true; + await options.deps.sleep(options.pollMs); + } + return !isOriginalProcessAlive(); +} + +async function readPid(pidPath: string): Promise { + try { + const raw = await fs.readFile(pidPath, 'utf-8'); + const pid = Number(raw.trim()); + return Number.isInteger(pid) && pid > 0 ? pid : undefined; + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw err; + } +} + +async function readStatusFile(statusPath: string): Promise { + try { + const parsed = JSON.parse(await fs.readFile(statusPath, 'utf-8')) as WatchStatusRecord; + return parsed && typeof parsed === 'object' ? parsed : undefined; + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + return { + state: 'error', + message: `unable to read status file: ${(err as Error).message}`, + updatedAt: new Date().toISOString(), + }; + } +} + +function stopRequestPath(paths: AutoSyncWatchPaths, ownerId: string): string { + if (!isSafeWatchOwnerId(ownerId)) { + throw new Error('watch ownerId is not a safe filename component'); + } + return path.join(path.dirname(paths.pidPath), `watch.stop.${ownerId}.json`); +} + +async function readStopRequest(filePath: string): Promise { + try { + const parsed = JSON.parse(await fs.readFile(filePath, 'utf-8')) as WatchStopRequestRecord; + if ( + parsed && + typeof parsed === 'object' && + Number.isInteger(parsed.pid) && + parsed.pid > 0 && + typeof parsed.ownerId === 'string' && + parsed.ownerId && + typeof parsed.processStartTime === 'string' && + parsed.processStartTime && + typeof parsed.requestedAt === 'string' && + parsed.requestedAt + ) { + return parsed; + } + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') return undefined; + } + return undefined; +} + +async function writeWatchStatus( + paths: AutoSyncWatchPaths, + record: WatchStatusRecord, +): Promise { + await fs.mkdir(path.dirname(paths.statusPath), { recursive: true }); + const tmpPath = `${paths.statusPath}.tmp.${process.pid}.${Date.now()}`; + await fs.writeFile(tmpPath, `${JSON.stringify(record, null, 2)}\n`, 'utf-8'); + await fs.rename(tmpPath, paths.statusPath); +} + +async function writeWatchOwner(paths: AutoSyncWatchPaths, record: WatchOwnerRecord): Promise { + await writeAtomicText(paths.ownerPath, `${JSON.stringify(record, null, 2)}\n`); +} + +async function cleanupWatchFiles( + paths: AutoSyncWatchPaths, + ownerId: string, + releaseLock: () => Promise, +): Promise { + try { + const owner = await readOwnerFile(paths.ownerPath); + if (owner?.ownerId === ownerId) { + if ((await readPid(paths.pidPath)) === owner.pid) await removeIfExists(paths.pidPath); + if ((await readOwnerFile(paths.ownerPath))?.ownerId === ownerId) { + await removeIfExists(paths.ownerPath); + } + await removeIfExists(stopRequestPath(paths, ownerId)); + } + } finally { + await releaseLock(); + } +} + +async function writeAtomicText(filePath: string, content: string): Promise { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + const tmpPath = `${filePath}.tmp.${process.pid}.${Date.now()}`; + await fs.writeFile(tmpPath, content, 'utf-8'); + await fs.rename(tmpPath, filePath); +} + +async function removeIfExists(filePath: string): Promise { + await fs.rm(filePath, { force: true }); +} + +async function fileExists(filePath: string): Promise { + return fs.access(filePath).then( + () => true, + () => false, + ); +} + +function resolveWatchDeps(deps: Partial = {}): AutoSyncWatchControlDeps { + return { + isProcessAlive: deps.isProcessAlive ?? isProcessAlive, + readProcessCommand: + deps.readProcessCommand ?? + ((pid) => { + try { + const command = + process.platform === 'win32' + ? execFileSync( + 'powershell.exe', + [ + '-NoProfile', + '-NonInteractive', + '-Command', + `(Get-CimInstance Win32_Process -Filter \"ProcessId = ${pid}\").CommandLine`, + ], + { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }, + ).trim() + : execFileSync('ps', ['-p', String(pid), '-o', 'command='], { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + return command || undefined; + } catch { + return undefined; + } + }), + readProcessStartTime: deps.readProcessStartTime ?? readProcessStartTime, + sleep: + deps.sleep ?? + ((ms) => + new Promise((resolve) => { + setTimeout(resolve, ms); + })), + }; +} diff --git a/gitnexus/src/core/auto-sync/state.ts b/gitnexus/src/core/auto-sync/state.ts new file mode 100644 index 000000000..01f3ed2f5 --- /dev/null +++ b/gitnexus/src/core/auto-sync/state.ts @@ -0,0 +1,173 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { acquireFileLock, FileLockBusyError } from '../../storage/file-lock.js'; +import { getGlobalDir } from '../../storage/repo-manager.js'; + +export type AutoSyncAnalyzeStatus = 'success' | 'failed' | 'skipped' | 'threshold_skipped'; + +export interface AutoSyncCommitStateEntry { + codeCommitId: string; + analyzedCommitId?: string; + lastAnalyzeStatus?: AutoSyncAnalyzeStatus; + analyzeConsecutiveFailures?: number; + lastAnalyzeError?: string; + groupSyncPending?: boolean; + lastSyncTime: string; +} + +export type AutoSyncCommitState = Record; + +export function getAutoSyncWatchDir(gitnexusDir = getGlobalDir()): string { + return path.join(gitnexusDir, 'watch'); +} + +export function getAutoSyncMutexPath(gitnexusDir = getGlobalDir()): string { + return path.join(getAutoSyncWatchDir(gitnexusDir), 'watch.mutex'); +} + +export function getAutoSyncStatePath(gitnexusDir = getGlobalDir()): string { + return path.join(getAutoSyncWatchDir(gitnexusDir), 'auto-sync-state.json'); +} + +export function getProjectCommitInfoPath(gitnexusDir = getGlobalDir()): string { + return path.join(getAutoSyncWatchDir(gitnexusDir), 'project_commit_info.txt'); +} + +export async function resetAutoSyncState(gitnexusDir = getGlobalDir()): Promise { + let releaseLock: () => Promise; + try { + releaseLock = await acquireFileLock(getAutoSyncMutexPath(gitnexusDir)); + } catch (error) { + if (error instanceof FileLockBusyError) return false; + throw error; + } + + try { + await Promise.all([ + fs.rm(getAutoSyncStatePath(gitnexusDir), { force: true }), + fs.rm(getProjectCommitInfoPath(gitnexusDir), { force: true }), + ]); + return true; + } finally { + await releaseLock(); + } +} + +export function buildStateKey(repoPath: string, branch: string): string { + return `${path.resolve(repoPath)}|${branch}`; +} + +export function shouldAnalyzeCommit(input: { + currentCommit: string; + previousAnalyzedCommit?: string; + previousStatus?: AutoSyncAnalyzeStatus; +}): boolean { + if (!input.currentCommit) return false; + if (input.previousStatus === 'failed') return true; + return input.currentCommit !== input.previousAnalyzedCommit; +} + +export async function loadAutoSyncState( + statePath = getAutoSyncStatePath(), +): Promise { + try { + const raw = await fs.readFile(statePath, 'utf-8'); + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; + return Object.fromEntries( + Object.entries(parsed).filter((entry): entry is [string, AutoSyncCommitStateEntry] => + isAutoSyncCommitStateEntry(entry[1]), + ), + ); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return {}; + // Corrupt JSON is genuinely unrecoverable, so rebuilding is the only move. + // An unreadable file (EACCES, EIO, EISDIR) is different: the state is + // probably intact, and returning {} here would make the tick overwrite it, + // losing every repo's analyzed commit and failure count. + if (!(err instanceof SyntaxError)) throw err; + process.stderr.write( + `[auto-sync] Ignoring corrupt state file: ${statePath}. State will be rebuilt.\n`, + ); + return {}; + } +} + +function isAutoSyncCommitStateEntry(value: unknown): value is AutoSyncCommitStateEntry { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const entry = value as Record; + return ( + typeof entry.codeCommitId === 'string' && + typeof entry.lastSyncTime === 'string' && + (entry.analyzedCommitId === undefined || typeof entry.analyzedCommitId === 'string') && + (entry.lastAnalyzeStatus === undefined || + entry.lastAnalyzeStatus === 'success' || + entry.lastAnalyzeStatus === 'failed' || + entry.lastAnalyzeStatus === 'skipped' || + entry.lastAnalyzeStatus === 'threshold_skipped') && + (entry.analyzeConsecutiveFailures === undefined || + (typeof entry.analyzeConsecutiveFailures === 'number' && + Number.isInteger(entry.analyzeConsecutiveFailures) && + entry.analyzeConsecutiveFailures >= 0)) && + (entry.lastAnalyzeError === undefined || typeof entry.lastAnalyzeError === 'string') && + (entry.groupSyncPending === undefined || typeof entry.groupSyncPending === 'boolean') + ); +} + +export async function saveAutoSyncState( + state: AutoSyncCommitState, + statePath = getAutoSyncStatePath(), +): Promise { + await fs.mkdir(path.dirname(statePath), { recursive: true }); + const tmpPath = `${statePath}.tmp.${process.pid}.${Date.now()}`; + await fs.writeFile(tmpPath, `${JSON.stringify(state, null, 2)}\n`, 'utf-8'); + await fs.rename(tmpPath, statePath); +} + +export async function writeProjectCommitInfo( + entries: ProjectCommitInfoEntry[], + infoPath = getProjectCommitInfoPath(), +): Promise { + await fs.mkdir(path.dirname(infoPath), { recursive: true }); + const lines = [ + '# GitNexus auto-sync project commit info', + `updated_at: ${new Date().toISOString()}`, + '', + ...entries.flatMap((entry) => [ + `remote: ${entry.remoteUrl}`, + `local_path: ${entry.localPath}`, + `branch: ${entry.branch ?? ''}`, + `code_commit: ${entry.codeCommitId ?? ''}`, + `analyzed_commit: ${entry.analyzedCommitId ?? ''}`, + `status: ${entry.status}`, + `analyze_consecutive_failures: ${entry.analyzeConsecutiveFailures ?? 0}`, + ...(entry.analyzeFailureThreshold === undefined + ? [] + : [`analyze_failure_threshold: ${entry.analyzeFailureThreshold}`]), + ...(entry.lastAnalyzeError ? [`last_analyze_error: ${entry.lastAnalyzeError}`] : []), + `last_sync_time: ${entry.lastSyncTime}`, + '', + ]), + ]; + const tmpPath = `${infoPath}.tmp.${process.pid}.${Date.now()}`; + await fs.writeFile(tmpPath, `${lines.join('\n')}\n`, 'utf-8'); + await fs.rename(tmpPath, infoPath); +} + +export interface ProjectCommitInfoEntry { + remoteUrl: string; + localPath: string; + branch?: string; + codeCommitId?: string; + analyzedCommitId?: string; + status: + | AutoSyncAnalyzeStatus + | 'sync_failed' + | 'branch_skipped' + | 'branch_unavailable' + | 'sync_timeout'; + analyzeConsecutiveFailures?: number; + analyzeFailureThreshold?: number; + lastAnalyzeError?: string; + lastSyncTime: string; +} diff --git a/gitnexus/src/core/embeddings/http-client.ts b/gitnexus/src/core/embeddings/http-client.ts index 82cce622a..7919b2376 100644 --- a/gitnexus/src/core/embeddings/http-client.ts +++ b/gitnexus/src/core/embeddings/http-client.ts @@ -11,6 +11,7 @@ * via `AbortSignal.timeout` on the underlying fetch. */ +import { chunk } from '../../lib/utils.js'; import { CircuitOpenError, ResilientFetchExhaustedError, @@ -566,9 +567,7 @@ export const httpEmbed = async ( const url = `${config.baseUrl}/embeddings`; const allVectors: Float32Array[] = []; - for (let i = 0; i < texts.length; i += HTTP_BATCH_SIZE) { - const batch = texts.slice(i, i + HTTP_BATCH_SIZE); - const batchIndex = Math.floor(i / HTTP_BATCH_SIZE); + for (const [batchIndex, batch] of chunk(texts, HTTP_BATCH_SIZE).entries()) { const items = await httpEmbedBatch( url, batch, diff --git a/gitnexus/src/core/graph/import-cycles.ts b/gitnexus/src/core/graph/import-cycles.ts index d5dc9eacd..a9bef2a8b 100644 --- a/gitnexus/src/core/graph/import-cycles.ts +++ b/gitnexus/src/core/graph/import-cycles.ts @@ -1,11 +1,568 @@ +/** + * Elementary import-cycle enumeration. + * + * ## What is reported + * + * Every *elementary* cycle of the file-import graph — a closed walk that visits + * no file twice — is reported exactly once. Self-imports (`a -> a`) and + * two-file cycles count. Cycles that are nested inside, or that overlap with, + * other cycles are each reported separately: a strongly connected component + * with three mutually-importing files contributes five cycles, not one. + * + * This replaces an earlier implementation that returned ONE representative + * cycle per cyclic strongly connected component. That made the reported count a + * count of tangles, not of cycles, and it hid every cycle in a component but + * the first — including cycles that a reader would have to break separately. + * The tangle count is still available, as `componentCount`, under a name that + * says what it is. + * + * The scale of what the old shape hid, measured on GitNexus itself (2,079 + * files, 5,320 initialization-forcing import edges): it reported 11 cycles. + * There are 27,939, spread across those same 11 components. It showed 11 of + * them and 27,928 were invisible. + * + * ## Algorithm + * + * Donald B. Johnson, "Finding all the elementary circuits of a directed graph", + * SIAM J. Comput. 4(1), 1975 — SCC decomposition plus a backtracking search + * guarded by the `blocked` flag and the `B` sets, which together guarantee that + * no fruitless path is explored twice between two circuit outputs. That is what + * buys the O((n + e)(c + 1)) bound for `c` circuits: the cost is proportional + * to the answer, not to the size of the search space. + * + * SCCs come from an iterative Tarjan pass rather than the Kosaraju pass this + * module used before. Johnson recomputes SCCs on each induced subgraph as the + * root advances, and Tarjan needs only the forward adjacency, so nothing has to + * rebuild a reverse graph once per root. + * + * ## How this differs from madge + * + * madge's `circular()` walks depth-first from every node carrying its ancestor + * path and records `ancestors.slice(indexOf(dep))` whenever it reaches an + * ancestor. It also marks nodes visited *globally* and skips them on later + * walks, so once a node has been traversed, cycles reachable only by entering + * it from a different predecessor are never seen. madge therefore reports many + * cycles but not all of them, and which ones it misses depends on iteration + * order. Johnson's is strictly stronger: it is complete. + * + * The practical consequence is that GitNexus reports MORE cycles than madge on + * the same graph, and the two counts should not be expected to agree. Anyone + * reconciling the two is not looking at a bug here. + * + * ## Determinism + * + * Adjacency lists and the node order are sorted (default string order, matching + * `Array.prototype.sort`), the search visits neighbours in that order, and the + * finished list is sorted element-wise. Same input, same output, byte for byte. + * + * ## Rotation normalization + * + * `[a, b, c, a]` and `[b, c, a, b]` are the same cycle and must be emitted + * once. That is structural here rather than a post-hoc dedup pass: Johnson's + * search for circuits rooted at `s` runs on the subgraph induced by the nodes + * that sort at or after `s`, so every node of an emitted circuit sorts at or + * after its root. Each cycle is therefore emitted exactly once, rooted at — and + * closed back onto — its own lexicographically smallest node. No other rotation + * of it can ever be produced. + * + * ## Bounds + * + * The number of elementary cycles is exponential in the worst case, so the + * search is bounded twice: by the number of cycles (`IMPORT_CYCLE_LIMIT`) and + * by the work spent finding them (`IMPORT_CYCLE_WORK_LIMIT`). The second is not + * redundant — Johnson's is output-sensitive, so a graph that yields few cycles + * per root can burn unbounded time while staying far under the cycle cap. + * + * Exceeding either bound abandons the enumeration. What a partial run had + * accumulated is discarded rather than returned, because a partial list of + * elementary cycles is indistinguishable from a complete one at the call site + * and would be read as "these are all of them". What is returned instead is a + * different KIND of list — one representative cycle per cyclic component, the + * old pre-enumeration answer — under `enumeration: 'component-representatives'` + * so the difference is machine-readable and not merely documented. Only a run + * that dies inside the decomposition itself reports nothing at all. + */ + +import { compareCodeUnits } from '../../lib/utils.js'; + interface ImportEdge { source: string; target: string; } -function findCyclePath(component: string[], adjacency: Map): string[] { +/** + * Elementary cycles reported before the search fails closed. + * + * The binding constraint is response size, not time. THE MEASUREMENT THAT SETS + * THIS NUMBER, on GitNexus itself — 2,079 files, 5,320 initialization-forcing + * import edges: complete enumeration finds 27,939 elementary cycles across 11 + * components in 241ms. Fast. But those cycles average 13 files each, so + * serializing them is 400,877 path entries — a 21.8 MB JSON response for a tool + * whose result is read by an agent. Time was never going to stop that, and + * neither was the work budget (the same run spends 6.3M of its 10M). + * + * Keep that measurement next to this constant. Without it the cap looks like an + * arbitrary round number and gets raised or deleted by someone who has only + * ever seen it not fire. + * + * So the cap is set where the answer stops being consumable rather than where + * the machine stops coping. Past 10,000 cycles the ten-thousandth path tells a + * reader nothing the first hundred did not, and what a reader acts on is + * `componentCount` plus one cycle per component — which is exactly what a + * report over the cap degrades to, rather than to nothing. + */ +export const IMPORT_CYCLE_LIMIT = 10_000; + +/** + * Units of search effort allowed before the search fails closed — edges + * examined, nodes scanned per root, and emitted cycle nodes at + * `EMITTED_NODE_COST` each — counted across the SCC passes and the circuit + * search alike. + * + * The cycle cap alone does NOT bound this. Johnson's is output-sensitive at + * O((n + e)(c + 1)), so producing `c` cycles still scales with the graph: a + * component of mutually-importing neighbours yields one or two cycles per root, + * so an SCC pass runs per node and the total is quadratic while the cycle count + * stays low. `check` admits import graphs up to 100k edges, so that shape is + * reachable, and there it is minutes of work under a cycle cap that never + * trips. The reverse gap is just as real: a single 50k-file component produces + * cycles 50k files long, and 10k of those exhaust the heap. One bound cannot + * see both, which is why there are two. + * + * Measured on this implementation against the mutual-import chain — the shape + * that spends the whole budget, where every unit buys a fresh SCC pass over a + * barely-smaller component — the rate is 2.9-5.2M units/second (5.2M at 10k + * nodes, 2.9M at 50k; it falls as the component grows). So 10M buys roughly + * 1.9-3.5s of enumeration on this hardware. That is the one shape where a user + * waits, and it is the number to re-measure if this constant is ever moved. + * + * It sits far above what real import graphs cost: a 100k-file acyclic graph + * spends 220k units, and 20k independent three-file tangles spend 576k. Only a + * component both large and densely tangled reaches the cap, and that + * component's honest answer is "too tangled to enumerate", not a + * silently-shortened list. + */ +export const IMPORT_CYCLE_WORK_LIMIT = 10_000_000; + +/** + * Work charged per node of an emitted cycle, relative to one edge examination. + * + * Emitted nodes are retained for the lifetime of the call and then sorted and + * serialized, so they are the term that decides peak memory, while examined + * edges cost nothing but time. Without a weight here, a graph whose cycles are + * tens of thousands of files long exhausts the heap while both bounds still + * read as comfortably unspent. + */ +const EMITTED_NODE_COST = 10; + +/** Which bound stopped the search. */ +export type ImportCycleLimit = 'cycles' | 'work'; + +/** + * The result of an enumeration. + * + * `enumeration` is the union's discriminant rather than a sibling flag, + * deliberately: a caller cannot reach `cycles` without first narrowing on what + * kind of list it is holding. A partial enumeration and a complete one are + * indistinguishable by inspection — both are arrays of real cycles — so the + * difference has to be carried in the type, not in a comment or a count that + * happens to look small. + */ +export type ImportCycleReport = + | { + readonly enumeration: 'complete'; + /** + * Every elementary cycle, each as `[n0, n1, ..., nk, n0]` — the first + * node repeated at the end so the closing edge is explicit. Sorted. + */ + readonly cycles: readonly string[][]; + /** + * Number of cyclic strongly connected components — the count of + * independent tangles. This is what the previous implementation called + * the cycle count; it is NOT the number of cycles. + */ + readonly componentCount: number; + } + | { + /** + * A bound was hit, so the enumeration is abandoned — but the SCC + * decomposition had already finished, so every tangle is known and each + * one gets a representative. This is strictly more useful than an error: + * a CI job can act on "these 11 components are cyclic, here is one cycle + * through each", and cannot act on nothing at all. + * + * What is NOT carried is any count of cycles. `componentCount` is exact; + * the number of elementary cycles is unknown and stays unknown. + */ + readonly enumeration: 'component-representatives'; + /** One cycle per component, same shape and ordering as the complete list. */ + readonly cycles: readonly string[][]; + readonly componentCount: number; + readonly reason: ImportCycleLimit; + readonly limit: number; + } + | { + /** + * A bound was hit inside the decomposition itself, so not even the tangle + * count is known. There is genuinely nothing to report. + */ + readonly enumeration: 'none'; + readonly reason: ImportCycleLimit; + readonly limit: number; + }; + +/** Sorted forward adjacency plus the set of nodes that import themselves. */ +interface ImportGraph { + readonly adjacency: ReadonlyMap; + readonly nodes: readonly string[]; + readonly selfLoops: ReadonlySet; +} + +function buildGraph(edges: readonly ImportEdge[]): ImportGraph { + const targetsBySource = new Map>(); + for (const { source, target } of edges) { + if (!source || !target) continue; + const targets = targetsBySource.get(source) ?? new Set(); + targets.add(target); + targetsBySource.set(source, targets); + if (!targetsBySource.has(target)) targetsBySource.set(target, new Set()); + } + + const adjacency = new Map(); + const selfLoops = new Set(); + for (const [source, targets] of targetsBySource) { + adjacency.set(source, [...targets].sort()); + if (targets.has(source)) selfLoops.add(source); + } + return { adjacency, nodes: [...adjacency.keys()].sort(), selfLoops }; +} + +interface CircuitSearch { + readonly cycles: string[][]; + readonly cycleLimit: number; + readonly workLimit: number; + /** Search effort so far, across the SCC passes and the circuit search alike. */ + work: number; + /** Non-null once a bound is hit; every loop unwinds on it. */ + exceeded: ImportCycleLimit | null; +} + +/** + * Charge `amount` units of search effort. Returns true once the budget is + * spent, which every caller must honour — a bulk charge that is not checked + * would let the search run on past the bound it just crossed. + */ +function overBudget(search: CircuitSearch, amount = 1): boolean { + search.work += amount; + if (search.work <= search.workLimit) return false; + search.exceeded = 'work'; + return true; +} + +/** + * Strongly connected components of the subgraph induced by `allowed`, via an + * iterative Tarjan. Iterative because import graphs reach 10^5 files and a + * recursive walk would blow the stack long before that. + * + * `roots` fixes the order the outer loop starts from, which is what makes the + * component set — and so Johnson's choice of root — deterministic. + * + * Abandons the pass and returns a partial list if the work budget runs out, so + * every caller must check `search.exceeded` before using the result. + */ +function stronglyConnectedComponents( + roots: readonly string[], + adjacency: ReadonlyMap, + allowed: ReadonlySet, + search: CircuitSearch, +): string[][] { + const index = new Map(); + const lowLink = new Map(); + const onStack = new Set(); + const pending: string[] = []; + const components: string[][] = []; + let counter = 0; + // One pass over the roots happens even for a component with no edges left. + if (overBudget(search, roots.length)) return components; + + for (const root of roots) { + if (index.has(root)) continue; + index.set(root, counter); + lowLink.set(root, counter); + counter += 1; + pending.push(root); + onStack.add(root); + const frames = [{ node: root, nextIndex: 0 }]; + + while (frames.length > 0) { + const frame = frames[frames.length - 1]; + const neighbors = adjacency.get(frame.node) ?? []; + if (frame.nextIndex < neighbors.length) { + const next = neighbors[frame.nextIndex]; + frame.nextIndex += 1; + if (overBudget(search)) return components; + if (!allowed.has(next)) continue; + if (!index.has(next)) { + index.set(next, counter); + lowLink.set(next, counter); + counter += 1; + pending.push(next); + onStack.add(next); + frames.push({ node: next, nextIndex: 0 }); + } else if (onStack.has(next)) { + lowLink.set(frame.node, Math.min(lowLink.get(frame.node)!, index.get(next)!)); + } + continue; + } + + frames.pop(); + const node = frame.node; + if (lowLink.get(node)! === index.get(node)!) { + const component: string[] = []; + for (;;) { + const member = pending.pop()!; + onStack.delete(member); + component.push(member); + if (member === node) break; + } + components.push(component); + } + if (frames.length > 0) { + const parent = frames[frames.length - 1].node; + lowLink.set(parent, Math.min(lowLink.get(parent)!, lowLink.get(node)!)); + } + } + } + + return components; +} + +/** A component that contains at least one cycle: two-plus members, or a self-import. */ +function isCyclic(component: readonly string[], selfLoops: ReadonlySet): boolean { + return component.length > 1 || selfLoops.has(component[0]); +} + +function leastNode(nodes: readonly string[]): string { + let least = nodes[0]; + for (const node of nodes) if (node < least) least = node; + return least; +} + +/** Order components by their least node. Components are disjoint, so this is total. */ +function byLeastNode(left: readonly string[], right: readonly string[]): number { + const leftLeast = leastNode(left); + const rightLeast = leastNode(right); + return compareCodeUnits(leftLeast, rightLeast); +} + +/** + * Johnson's `UNBLOCK`, iterative. Lifts `node` and everything transitively + * waiting on it out of `blocked`, so a path that was abandoned as fruitless + * becomes explorable again once the reason it was fruitless is gone. + */ +function unblock(node: string, blocked: Set, blockedBy: Map>): void { + const stack = [node]; + while (stack.length > 0) { + const current = stack.pop()!; + blocked.delete(current); + const waiting = blockedBy.get(current); + if (waiting === undefined || waiting.size === 0) continue; + for (const dependent of waiting) { + if (blocked.has(dependent)) stack.push(dependent); + } + waiting.clear(); + } +} + +/** + * Johnson's `CIRCUIT`, iterative — enumerate the elementary circuits rooted at + * `root` inside `allowed`. + * + * Every circuit found here starts and ends at `root`, and `root` is the least + * node of `allowed` by construction, which is where the rotation guarantee in + * the module docblock comes from. + */ +function enumerateCircuitsFrom( + root: string, + adjacency: ReadonlyMap, + allowed: ReadonlySet, + search: CircuitSearch, +): void { + const blocked = new Set([root]); + const blockedBy = new Map>(); + const path = [root]; + // `neighbors` rides the frame: the list is fixed for a node, while this loop + // re-enters per DFS STEP — ~2.6M iterations against 395k pushes on this + // repository, so looking it up per iteration re-hashes the path each time. + const frames = [ + { node: root, nextIndex: 0, foundCircuit: false, neighbors: adjacency.get(root) ?? [] }, + ]; + + while (frames.length > 0) { + // Budget spent: return rather than unwind. Everything this function owns is + // local and it returns void, so draining the stack would run the full + // `blockedBy` bookkeeping (or an `unblock` walk) per frame, to no effect, + // on exactly the graphs already judged too expensive. + if (search.exceeded !== null) return; + const frame = frames[frames.length - 1]; + const neighbors = frame.neighbors; + + if (frame.nextIndex < neighbors.length) { + const next = neighbors[frame.nextIndex]; + frame.nextIndex += 1; + if (overBudget(search)) continue; + if (!allowed.has(next)) continue; + if (next === root) { + // `path` is the elementary path root -> ... -> frame.node; closing it + // back onto the root yields the cycle in the documented shape. A + // self-import lands here on the first step with path === [root]. + search.cycles.push([...path, root]); + frame.foundCircuit = true; + // One-past, matching the edge-limit guard in `check`: `cycleLimit` + // cycles is an acceptable answer, and it takes finding one MORE to + // prove the graph overflowed. Stopping at `>= cycleLimit` would fail a + // graph that has exactly that many cycles and could have been reported + // in full. + // + // Tested before the emission charge so that a graph over both bounds + // reports the cycle cap, which is the one a reader can act on, rather + // than whichever happened to trip first. + if (search.cycles.length > search.cycleLimit) { + search.exceeded = 'cycles'; + continue; + } + // A found cycle is not merely traversed: it is copied, retained until + // the call returns, sorted, and serialized into an MCP response. So it + // is charged at EMITTED_NODE_COST per node, not 1. This is what bounds + // MEMORY as well as time — 10,000 cycles is a modest cap when cycles + // are four files long and a heap-exhausting one when a single strongly + // connected component is 50,000 files around. + overBudget(search, (path.length + 1) * EMITTED_NODE_COST); + continue; + } + if (!blocked.has(next)) { + blocked.add(next); + path.push(next); + frames.push({ + node: next, + nextIndex: 0, + foundCircuit: false, + neighbors: adjacency.get(next) ?? [], + }); + } + continue; + } + + // Leaving `frame.node`. If it reached the root, it may lie on further + // circuits, so it and its waiters go back in play. If it did not, it is + // recorded as a dead end on each of its successors: it stays blocked until + // one of them is unblocked, which is the pruning that makes Johnson's + // output-sensitive rather than exponential in the graph size. + frames.pop(); + path.pop(); + if (frame.foundCircuit) { + unblock(frame.node, blocked, blockedBy); + } else { + for (const next of neighbors) { + if (!allowed.has(next)) continue; + // `set` only when the entry is created: re-setting an existing key + // re-hashes the path string for no effect, and this runs once per + // out-edge of every unwound frame — measured at 1.06M redundant + // `Map.set` calls on this repository's own import graph. + let waiting = blockedBy.get(next); + if (waiting === undefined) { + waiting = new Set(); + blockedBy.set(next, waiting); + } + waiting.add(frame.node); + } + } + if (frames.length > 0 && frame.foundCircuit) { + frames[frames.length - 1].foundCircuit = true; + } + } +} + +/** + * Johnson's outer loop over one cyclic component: search the circuits rooted at + * the component's least node, drop that node, and repeat on whatever cyclic + * components the remainder falls into. + * + * Dropping the root is the whole rotation guarantee. Every cycle left after the + * drop consists of nodes greater than every root taken so far, so when the + * component holding it finally has that cycle's own minimum as its least node, + * the cycle is emitted once, rooted there. No other rotation is reachable, + * because the other rotations' starting nodes have already been excluded or are + * not the component's least. + * + * Re-decomposing the REMAINDER rather than the original node range also keeps + * each pass proportional to what is left: a tangle that falls apart when its + * busiest file is removed stops costing anything immediately. + */ +function enumerateComponentCycles( + component: readonly string[], + graph: ImportGraph, + search: CircuitSearch, +): void { + // Components still to search. Pushed so that they pop in increasing order of + // least node — see the sort below. + const stack: string[][] = [[...component]]; + + while (stack.length > 0 && search.exceeded === null) { + const current = stack.pop()!; + const root = leastNode(current); + // Scanning for the root and materializing the allowed set both cost one + // pass over the component, and both happen once per root, so they are the + // O(n^2) term on a component that never splits. Charged, or the budget + // would not see the work it exists to bound. + if (overBudget(search, current.length)) return; + enumerateCircuitsFrom(root, graph.adjacency, new Set(current), search); + if (search.exceeded !== null) return; + + const remaining = current.filter((node) => node !== root); + if (remaining.length === 0) continue; + // Deliberately NOT re-sorted: the SCC set is independent of the order its + // roots are visited in, `leastNode` picks Johnson's root regardless, and + // the finished cycle list is sorted at the end. Sorting here would add an + // O(n log n) term to every root for no observable difference. + const decomposed = stronglyConnectedComponents( + remaining, + graph.adjacency, + new Set(remaining), + search, + ); + // Same rule as above the call: once the budget is spent the `while` will + // refuse to pop whatever we push, so the filter/decorate/sort is waste. + if (search.exceeded !== null) return; + const subComponents = decomposed + .filter((subComponent) => isCyclic(subComponent, graph.selfLoops)) + .map((subComponent) => ({ least: leastNode(subComponent), nodes: subComponent })) + // Descending, so the stack pops them in increasing order of least node — + // Johnson's root order, and what makes a budget-stopped run stop at a + // deterministic point rather than wherever iteration happened to be. + .sort((a, b) => -compareCodeUnits(a.least, b.least)); + for (const subComponent of subComponents) stack.push(subComponent.nodes); + } +} + +/** + * The shortest cycle through a component's least node, by breadth-first search + * across the component. + * + * This is the fallback when a bound stops the full enumeration: one concrete, + * checkable cycle naming each tangle. It is also exactly what this module + * returned for every component before elementary enumeration existed, so the + * degraded answer is no worse than the old complete answer. + * + * Linear in the component, and it runs only after the decomposition has already + * succeeded, so it cannot fail the way the enumeration did. The budget is + */ +function representativeCycle( + component: readonly string[], + adjacency: ReadonlyMap, +): string[] { const allowed = new Set(component); - const start = component[0]; + const start = leastNode(component); const parents = new Map([[start, null]]); const queue = [start]; @@ -29,82 +586,73 @@ function findCyclePath(component: string[], adjacency: Map): s } } - throw new Error('Invariant violation: no cycle found through SCC root.'); + // Unreachable: every component reaching here is cyclic, and BFS from its + // least node inside the component must close. Thrown rather than returned + // empty so a future change that breaks the invariant is loud. + throw new Error('Invariant violation: no cycle found through cyclic component root.'); +} + +/** Element-wise lexicographic order, so the finished list is byte-stable. */ +function compareCycles(left: readonly string[], right: readonly string[]): number { + const shared = Math.min(left.length, right.length); + for (let index = 0; index < shared; index += 1) { + const order = compareCodeUnits(left[index], right[index]); + if (order !== 0) return order; + } + return left.length - right.length; } /** - * Return one deterministic concrete cycle for every cyclic strongly connected - * component in the file import graph. + * Enumerate every elementary cycle in the file import graph. + * + * The result is discriminated on `enumeration`; see `ImportCycleReport` for + * what each variant carries. Past either bound the enumeration is discarded + * rather than truncated — see the module docblock for the algorithm, the + * rotation rule, and why a partial cycle list is not a safe thing to return. */ -export function findImportCycles(edges: ImportEdge[]): string[][] { - const adjacency = new Map>(); - for (const { source, target } of edges) { - if (!source || !target) continue; - const targets = adjacency.get(source) ?? new Set(); - targets.add(target); - adjacency.set(source, targets); - if (!adjacency.has(target)) adjacency.set(target, new Set()); +export function findImportCycles( + edges: readonly ImportEdge[], + cycleLimit: number = IMPORT_CYCLE_LIMIT, + workLimit: number = IMPORT_CYCLE_WORK_LIMIT, +): ImportCycleReport { + const graph = buildGraph(edges); + const allNodes = new Set(graph.nodes); + const search: CircuitSearch = { cycles: [], cycleLimit, workLimit, work: 0, exceeded: null }; + + const decomposition = stronglyConnectedComponents(graph.nodes, graph.adjacency, allNodes, search); + // Only a decomposition that ran to completion has a trustworthy count; one + // abandoned mid-pass would undercount silently. + const decompositionComplete = search.exceeded === null; + const cyclicComponents = decomposition + .filter((component) => isCyclic(component, graph.selfLoops)) + .sort(byLeastNode); + + for (const component of cyclicComponents) { + if (search.exceeded !== null) break; + enumerateComponentCycles(component, graph, search); } - const sortedAdjacency = new Map( - [...adjacency].map(([node, targets]) => [node, [...targets].sort()] as const), - ); - const reverseAdjacency = new Map(); - for (const node of sortedAdjacency.keys()) reverseAdjacency.set(node, []); - for (const [source, targets] of sortedAdjacency) { - for (const target of targets) reverseAdjacency.get(target)!.push(source); + if (search.exceeded !== null) { + const reason = search.exceeded; + const limit = reason === 'cycles' ? cycleLimit : workLimit; + if (!decompositionComplete) return { enumeration: 'none', reason, limit }; + // Whatever the abandoned enumeration accumulated is discarded — it is a + // partial list of elementary cycles and would read as a complete one. + // Representatives are a different KIND of list, one per component, and the + // report says so in the type. + return { + enumeration: 'component-representatives', + cycles: cyclicComponents + .map((component) => representativeCycle(component, graph.adjacency)) + .sort(compareCycles), + componentCount: cyclicComponents.length, + reason, + limit, + }; } - for (const sources of reverseAdjacency.values()) sources.sort(); - - const visited = new Set(); - const finishOrder: string[] = []; - const components: string[][] = []; - - for (const start of [...sortedAdjacency.keys()].sort()) { - if (visited.has(start)) continue; - visited.add(start); - const stack = [{ node: start, nextIndex: 0 }]; - while (stack.length > 0) { - const frame = stack[stack.length - 1]; - const neighbors = sortedAdjacency.get(frame.node) ?? []; - if (frame.nextIndex < neighbors.length) { - const next = neighbors[frame.nextIndex++]; - if (!visited.has(next)) { - visited.add(next); - stack.push({ node: next, nextIndex: 0 }); - } - } else { - finishOrder.push(frame.node); - stack.pop(); - } - } - } - - visited.clear(); - for (let index = finishOrder.length - 1; index >= 0; index -= 1) { - const start = finishOrder[index]; - if (visited.has(start)) continue; - const component: string[] = []; - const stack = [start]; - visited.add(start); - while (stack.length > 0) { - const node = stack.pop()!; - component.push(node); - for (const next of reverseAdjacency.get(node) ?? []) { - if (visited.has(next)) continue; - visited.add(next); - stack.push(next); - } - } - component.sort(); - components.push(component); - } - - return components - .filter( - (component) => - component.length > 1 || (sortedAdjacency.get(component[0]) ?? []).includes(component[0]), - ) - .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)) - .map((component) => findCyclePath(component, sortedAdjacency)); + return { + enumeration: 'complete', + cycles: search.cycles.sort(compareCycles), + componentCount: cyclicComponents.length, + }; } diff --git a/gitnexus/src/core/group/PIPELINE.md b/gitnexus/src/core/group/PIPELINE.md index 9a8c4b972..72961cca3 100644 --- a/gitnexus/src/core/group/PIPELINE.md +++ b/gitnexus/src/core/group/PIPELINE.md @@ -16,10 +16,12 @@ flowchart TD D --> E1[TopicExtractor] D --> E2[HttpRouteExtractor] D --> E3[GrpcExtractor] + D --> E4[GraphqlExtractor] E1 --> F[ExtractedContract array
per repo] E2 --> F E3 --> F + E4 --> F B --> M[ManifestExtractor] M --> G[Manifest contracts
+ 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/.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 diff --git a/gitnexus/src/core/group/bridge-db.ts b/gitnexus/src/core/group/bridge-db.ts index 15bb1fb15..3c8c07bf2 100644 --- a/gitnexus/src/core/group/bridge-db.ts +++ b/gitnexus/src/core/group/bridge-db.ts @@ -1,18 +1,27 @@ import fsp from 'node:fs/promises'; import path from 'node:path'; -import { createHash, randomBytes } from 'node:crypto'; +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 { recordedMatchStages, recordedRepoList } from './completeness.js'; import { closeLbugConnection, openLbugConnection, type LbugConnectionHandle, } from '../lbug/lbug-config.js'; import { dedupeContracts, dedupeCrossLinks } from './normalization.js'; +import { withGroupSyncLock } from './group-lock.js'; import { createLogger } from '../logger.js'; -import { retryRename } from '../../storage/fs-atomic.js'; +import { retryRename, writeFileAtomic } from '../../storage/fs-atomic.js'; const bridgeLogger = createLogger('bridge-db', { debugEnvVar: 'GITNEXUS_DEBUG_BRIDGE', @@ -647,38 +656,347 @@ export async function closeBridgeDb(handle: BridgeHandle): Promise { /* ------------------------------------------------------------------ */ export async function writeBridgeMeta(groupDir: string, meta: BridgeMeta): Promise { - const target = path.join(groupDir, 'meta.json'); - // Unpredictable suffix + O_EXCL via `'wx'` flag closes the symlink/ - // pre-create attack window. The third argument `0o600` is the - // user-only mode mask — CodeQL's `js/insecure-temporary-file` query - // sources its verdict from the `mode` argument, NOT from `flags`: - // its `isSecureMode(mode)` predicate requires the low 6 bits to be - // zero (no group/world bits). Without an explicit mode the file is - // created with the process umask (typically 0o644 = group/world - // readable), which the query treats as the actual vulnerability. - // Both `'wx'` (runtime O_EXCL) AND `0o600` (CodeQL-credited mode) - // are needed: one closes the symlink race, the other closes the - // permissions exposure. - const tmp = `${target}.tmp.${randomBytes(8).toString('hex')}`; - const handle = await fsp.open(tmp, 'wx', 0o600); - try { - await handle.writeFile(JSON.stringify(meta, null, 2), 'utf-8'); - } finally { - await handle.close(); - } - // Use retryRename for consistency with writeBridge's atomic swap — on - // Windows a concurrent reader can cause EBUSY/EPERM even on a tiny - // meta.json, and we don't want meta write to be less robust than the - // bridge.lbug swap it accompanies. - await retryRename(tmp, target); + // 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)); } +/** + * Does `meta` still describe the `bridge.lbug` sitting next to it? + * + * `writeBridge` stamps the database's size and mtime into the metadata it + * writes, so a metadata file left over from an earlier sync cannot match a + * database that was replaced after it. Callers whose answer depends on the + * metadata being true of THIS database (cross-repo impact reads completeness + * from it) must not treat a mismatch as fact. + * + * When BOTH halves of the stamp are absent the metadata predates stamping, and + * it is judged on the write order of the two files instead — see + * {@link unstampedMetaPairsByWriteOrder}. Failing every unstamped metadata + * closed would mark all pre-existing bridges as incomplete until re-synced, + * trading a narrow window for a repo-wide regression; accepting them all hands + * back "verified" for the very window this pairing exists to catch. + * + * A stamp is a PAIR, so exactly one half present is rejected rather than waved + * through. That is not the legacy shape: something wrote a stamp and did not + * finish, which is the very condition stamping was added to detect. Joining the + * two `undefined` checks with `||` returned "verified" for precisely the shape + * that most deserves suspicion. + * + * Returns `false` when the database itself cannot be stat'd, on either path, + * since metadata describing a file that is not there describes nothing. + * + * The checks are ORDERED by how strong their evidence is, strongest first, and + * each later one is reached only because every earlier one had nothing to say. + * `provenanceUnknown` therefore comes first: a metadata file whose own writer + * says it cannot vouch for the database beside it has settled the question, and + * neither the stamp nor the write-order heuristic may overturn that. + * + * The marker is not decoration. `refreshPreservedBridgeMeta` rewrites this file + * atomically without touching the database, which leaves `meta.mtime` newer — + * the write order a paired write produces, and the one the unstamped branch + * ACCEPTS. Reading the marker after that branch (or not at all) hands back + * "verified" for a pair the same code path had just found broken. + */ +export async function bridgeMetaMatchesFile(groupDir: string, meta: BridgeMeta): Promise { + if (meta.provenanceUnknown) return false; + const stampedSize = meta.bridgeSize !== undefined; + const stampedMtime = meta.bridgeMtimeMs !== undefined; + if (!stampedSize && !stampedMtime) return unstampedMetaPairsByWriteOrder(groupDir); + if (!stampedSize || !stampedMtime) return false; + try { + const stat = await fsp.stat(path.join(groupDir, 'bridge.lbug')); + return stat.size === meta.bridgeSize && stat.mtimeMs === meta.bridgeMtimeMs; + } catch { + return false; + } +} + +/** + * Could the unstamped `meta.json` plausibly have been written by the sync that + * put this `bridge.lbug` beside it? + * + * `writeBridge` renames the database into place and writes the metadata AFTER, + * so `meta.mtime >= db.mtime` holds for any pair written together — including + * pairs written by builds from before the stamp existed, which is what makes + * this usable as back-compat rather than a repo-wide "re-sync everything". + * The only way to reach a database strictly NEWER than the metadata beside it + * is a swap whose metadata write did not land: the stale-meta-beside-a-new- + * database window, whose completeness `runGroupImpact` would otherwise spend as + * fact. + * + * This is a HEURISTIC ON WRITE ORDER, not proof of provenance. It answers "were + * these two written in the order a successful sync writes them?", and treats + * that as a proxy for "do these two belong together". It is wrong in two + * directions, and neither is theoretical: + * - FALSE ACCEPT, from a non-monotonic wall clock. `mtimeMs` is realtime, not + * monotonic, so an NTP step backwards, a VM snapshot restore or container + * clock skew between the database write and the metadata write can leave a + * genuinely mis-paired set reading as ordered. Anything that touches the + * stale metadata after a swap does the same — a restore from backup, an + * editor save, a copy that preserves only the database's times. The STAMP + * is what actually closes this; a pair that has one never reaches here. + * + * Coarse filesystem mtime granularity is NOT this hazard, despite looking + * like it: it collapses a pair written together to equal times, and equal + * is accepted, which is the correct verdict for that pair. + * + * - FALSE REJECT, from anything that rewrites the database's mtime after the + * metadata's — `cp -r`, `rsync` without `-t`, a machine move, a restore + * that replays files in directory order. An intact legacy pair is then + * demoted to a lower bound and stays there until the next successful sync + * re-stamps it; there is no other recovery, because nothing on the read + * path can distinguish it from the swap window it is imitating. + * + * This direction is the safe one — it degrades an answer to a floor rather + * than vouching for one — but it is a real, reachable cost, not a + * theoretical one, and it is NOT true that the rule can only ever demote + * pairs that were already broken. + * + * Equality counts as paired. On a filesystem with coarse mtime granularity both + * writes land in the same tick, and demanding a strictly newer metadata file + * would reject every legacy bridge there for a reason that is about the + * filesystem rather than about the bridge. + * + * A timestamp that cannot be measured is no match, the same convention the + * read-only handle cache applies to a bridge it could not stat: a comparison + * that could not be made is not a comparison that succeeded. + */ +async function unstampedMetaPairsByWriteOrder(groupDir: string): Promise { + try { + const [dbStat, metaStat] = await Promise.all([ + fsp.stat(path.join(groupDir, 'bridge.lbug')), + fsp.stat(path.join(groupDir, 'meta.json')), + ]); + return metaStat.mtimeMs >= dbStat.mtimeMs; + } catch { + return false; + } +} + +/** + * Read `meta.json`, validating the SHAPE of what it holds. + * + * The read and the parse have always been guarded — an absent or unparseable + * file answers `version: 0`, which every caller already treats as "no + * provenance". What was not guarded is a file that parses into something that + * is not this shape: `runGroupImpact` spread both repo lists directly into a + * `Set`, so a non-iterable there threw a TypeError out of the whole cross-repo + * query, from a point where the bridge lease had been taken and not yet + * released. A malformed file is a reason to answer "provenance unknown", never + * a reason to crash the question. + */ export async function readBridgeMeta(groupDir: string): Promise { + const unreadable: BridgeMeta = { version: 0, generatedAt: '', missingRepos: [] }; + let parsed: unknown; try { const content = await fsp.readFile(path.join(groupDir, 'meta.json'), 'utf-8'); - return JSON.parse(content) as BridgeMeta; + parsed = JSON.parse(content); } catch { - return { version: 0, generatedAt: '', missingRepos: [] }; + return unreadable; + } + // `JSON.parse` succeeds on `null`, `7` and `[]` too, and none of them are + // metadata. Reading `.version` off the first of those is a thrown TypeError; + // reading it off the others silently yields `undefined`, which passes the + // version gate as if the bridge had been vouched for. + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return unreadable; + + const raw = parsed as Partial; + const missingRepos = recordedRepoList(raw.missingRepos); + const unreadableRepos = recordedRepoList(raw.unreadableRepos); + // Each list is judged on its own: a file whose `unreadableRepos` is garbage + // can still carry a `missingRepos` that was genuinely measured, and throwing + // that away would turn one unknown into two. + const repoListsUnreadable = + (raw.missingRepos !== undefined && missingRepos === undefined) || + (raw.unreadableRepos !== undefined && unreadableRepos === undefined); + + const meta: BridgeMeta = { + ...raw, + // A version that is not a number cannot be compared against + // BRIDGE_SCHEMA_VERSION; `0` is this file's existing word for "provenance + // unknown", which is exactly what such a file gives us. + // `0` is this file's word for "no provenance". A version that is not a + // positive integer is not a schema version, and letting one through splits + // the four gates that read this field: `ensureBridgeReady` and + // `openBridgeDbReadOnly` both compare `> 0 && !== CURRENT` and would open + // the bridge, `bridgeExists` compares `=== 0 || === CURRENT` and would say + // it is not there, and `bridgeProvenanceUnknown` compares `=== 0` and would + // call the answer complete. Normalizing here keeps all four agreeing + // instead of teaching each one the same new case. + version: + Number.isInteger(raw.version) && (raw.version as number) > 0 ? (raw.version as number) : 0, + generatedAt: typeof raw.generatedAt === 'string' ? raw.generatedAt : '', + missingRepos: missingRepos ?? [], + }; + // Absent, not empty. `unreadableRepos` is optional and "not recorded" is a + // distinct state from "measured none", so an unusable value is dropped rather + // than carried through — `repoListsUnreadable` is what records that something + // 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; +} + +/* ------------------------------------------------------------------ */ +/* refreshPreservedBridgeMeta */ +/* ------------------------------------------------------------------ */ + +/** + * What a refresh did to `meta.json`. + * + * - `restamped` — the pair still matched, so the lists were refreshed + * and the stamp re-taken from the database on disk. + * - `provenance-unknown` — the pair did NOT match (or there is no database to + * match), so the lists were refreshed and the metadata + * marked as unable to vouch for the file beside it. + * - `no-bridge` — neither `meta.json` nor `bridge.lbug` exists, so + * there is no pair to keep honest and nothing written. + */ +export type PreservedBridgeMetaOutcome = 'restamped' | 'provenance-unknown' | 'no-bridge'; + +async function fileExists(filePath: string): Promise { + try { + await fsp.access(filePath); + return true; + } catch { + return false; + } +} + +/** + * Bring `meta.json`'s diagnostic lists up to date with a sync that PRESERVED + * the bridge instead of rebuilding it, without ever making the metadata claim + * more about the database than it did before. + * + * `syncGroup`'s total-failure path keeps the previous run's contracts and + * deliberately leaves `bridge.lbug` alone — the contracts that bridge holds are + * the ones being preserved. But `runGroupImpact` reads completeness from + * `meta.json`, not from `contracts.json`, so leaving the metadata alone too left + * the two files telling different stories: the registry said "this sync could + * not read svc/users" while a cross-repo query answered "complete, nothing + * depends on this" (R4/R6). + * + * The refresh is the whole difficulty. It rewrites `meta.json` atomically, so + * the file's mtime becomes now while the database's stays old — which is the + * write order a paired write produces, and precisely what + * `unstampedMetaPairsByWriteOrder` accepts. Three rules follow, and each of them + * is load-bearing: + * + * 1. Ask `bridgeMetaMatchesFile` FIRST, on the file as it stands. After the + * write the question is unanswerable, because the write is what destroys + * the evidence. + * 2. Re-stamp only when that answer was yes. Re-stamping a pair that already + * failed would MANUFACTURE the provenance the failure just denied — the + * same metadata/database mis-pairing stamping exists to prevent (KTD6). + * 3. When it was no, record `provenanceUnknown` explicitly and carry the + * existing stamp fields through verbatim. Writing "no stamp" instead is + * worse, not better: an unstamped file is judged on the two file times, + * and this write has just put them in the accepting order. + * + * Nothing here opens, reads, or writes the database. The only `stat` of it + * happens on the branch where the pair was just verified. + * + * NOT SPLIT into locked/unlocked halves the way {@link writeBridge} is, and + * deliberately. Its one caller is `syncGroup`'s preserve branch, which is + * already inside `withGroupSyncLock` — so this write is ALREADY serialized + * against every other sync of the group, and taking the lock here would be the + * second acquisition of a non-reentrant primitive that the split exists to + * avoid. An acquiring wrapper would therefore have zero production callers, + * and no test calls this function at all: it would be dead code standing in for + * a guarantee the caller already provides. If a caller outside the critical + * section ever appears, it needs the same treatment `writeBridge` got — a + * wrapper, not a lock moved down here. + */ +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 { + const dbPath = path.join(groupDir, 'bridge.lbug'); + const [metaOnDisk, dbOnDisk] = await Promise.all([ + fileExists(path.join(groupDir, 'meta.json')), + fileExists(dbPath), + ]); + // Nothing on either side of the pair. `readBridgeMeta` already answers + // `version: 0` — provenance unknown — for an absent file, so a file written + // here would say what the absence already says while inventing state for a + // bridge that has never existed. + if (!metaOnDisk && !dbOnDisk) return 'no-bridge'; + + const existing = await readBridgeMeta(groupDir); + const paired = await bridgeMetaMatchesFile(groupDir, existing); + + 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. 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); + if (stat) { + refreshed.bridgeSize = stat.size; + refreshed.bridgeMtimeMs = stat.mtimeMs; + await writeBridgeMeta(groupDir, refreshed); + return 'restamped'; + } + // The database disappeared between the pairing check and this stat. There + // is nothing left to stamp, so fall through and say so rather than write a + // stamp describing a file that is gone. + } + + refreshed.provenanceUnknown = true; + await writeBridgeMeta(groupDir, refreshed); + 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 { + try { + const existing = await readBridgeMeta(groupDir); + if (existing.version === 0) return false; + await writeBridgeMeta(groupDir, { ...existing, provenanceUnknown: true }); + return true; + } catch { + return false; } } @@ -691,6 +1009,29 @@ export interface WriteBridgeInput { crossLinks: CrossLink[]; repoSnapshots: Record; missingRepos: string[]; + /** + * Repos this sync could not extract from — see + * `ContractRegistry.unreadableRepos` for the full definition, which this + * field carries unchanged. + * + * Deliberately not restated here. The narrower wording this once had ("whose + * index could not be opened") described one of the two causes and silently + * excluded the other, an extractor that threw partway through — so the same + * field meant one thing on the registry, another on the bridge input, and a + * third on the result. One definition, referenced twice, cannot drift. + * + * Recorded in meta.json so cross-repo impact can tell "nothing depends on + * this" from "we could not look": the bridge built here is missing every + * 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[]; } /** @@ -725,7 +1066,33 @@ function errMessage(err: unknown): string { } } -export async function writeBridge( +/** + * Rebuild `bridge.lbug` and its `meta.json`, ASSUMING THE CALLER ALREADY HOLDS + * THE GROUP SYNC LOCK for `groupDir` (R9). + * + * PRECONDITION — the group lock is held. There is exactly one production call + * site, `syncGroup` in sync.ts, and it is already inside + * `withGroupSyncLock(groupDir, …)` when it gets here. Enforced by this comment + * rather than by a type, matching `registerRepoUnlocked` / `withRegistryLock` + * in repo-manager.ts, which splits the same shape for the same reason. + * + * WHY THE SPLIT EXISTS AT ALL. The swap this function performs — old database + * aside, temp database into place, then `meta.json` written as a SECOND + * operation — is the write two concurrent syncs can interleave into a pairing + * that never existed: one sync's metadata beside the other's database. That + * needs mutual exclusion. But taking the lock HERE would be a second + * acquisition of a non-reentrant primitive inside a region that already holds + * it, and it would hang every single sync on the happy path, not some rare + * interleave. So the exclusion is the caller's, and this function only states + * the precondition. {@link writeBridge} is the acquiring wrapper for callers + * who are not already inside that region. + * + * SCOPE — writer-writer only. The reader-side promotion of a leftover + * `bridge.lbug.bak` runs on ordinary reads, outside anybody's critical section; + * `bridgeMetaMatchesFile` remains the reader's defense there and is not + * replaced by this lock. + */ +export async function writeBridgeUnlocked( groupDir: string, input: WriteBridgeInput, ): Promise { @@ -985,11 +1352,42 @@ export async function writeBridge( } await removeLbugFile(bakPath); - // 4. Write meta.json + // 4. Write the new meta.json, STAMPED WITH THE FILE IT DESCRIBES. + // + // meta.json carries the bridge's completeness, and since #3011 that is + // load-bearing: `runGroupImpact` folds `unreadableRepos ∪ missingRepos` + // into its truncation fields. The swap above and this write are two + // operations, so a sync that stops between them leaves the previous sync's + // meta beside a new database — and reading that as fact is a confidently + // wrong answer about the one thing this channel exists to make legible. + // + // Deleting the old meta before the swap would decide which way that window + // fails, but at an unacceptable price: the rename of the old database is + // wrapped in a catch that also swallows a FAILED rename (a held read-only + // handle does this on Windows), so `writeBridge` can throw with the old, + // perfectly good database still in place — and its metadata already gone, + // unrecoverably, for as long as the swap keeps failing. + // + // So destroy nothing and pair the two instead: record the size and mtime of + // the database this metadata describes, and let readers check that the pair + // still belongs together (`bridgeMetaMatchesFile`). A stale meta cannot match + // a freshly renamed database, and a sync that fails before the swap leaves a + // matching pair untouched. + const finalStat = await fsp.stat(finalPath); await writeBridgeMeta(groupDir, { version: BRIDGE_SCHEMA_VERSION, generatedAt: new Date().toISOString(), + bridgeSize: finalStat.size, + bridgeMtimeMs: finalStat.mtimeMs, missingRepos: input.missingRepos, + // Persisted whenever the caller supplied it, `[]` included: an empty list + // is the measurement "this sync accounted for every repo", and it is a + // 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; @@ -1005,6 +1403,33 @@ export async function writeBridge( } } +/** + * Rebuild `bridge.lbug` and its `meta.json` as the only writer of `groupDir`. + * + * The acquiring half of the split described on {@link writeBridgeUnlocked}: for + * callers that are NOT already inside the group's critical section, this takes + * the group sync lock around the whole swap and releases it afterwards. Two + * concurrent calls therefore run one after the other, so the `meta.json` left + * on disk is stamped for the `bridge.lbug` left on disk instead of for the + * loser's, which is the pairing the swap-plus-metadata sequence would otherwise + * let them interleave into. + * + * NOT used by `syncGroup`, and it must not be: that path already holds this + * lock, and `acquireIndexLock` is not reentrant, so routing it here would make + * every ordinary sync wait out the full `GROUP_SYNC_LOCK_TIMEOUT_MS` ceiling + * against itself. It calls {@link writeBridgeUnlocked} directly. + * + * Fails closed exactly as `withGroupSyncLock` does: if the lock cannot be + * acquired, a `GroupSyncLockError` is thrown and NOTHING is written — + * `bridge.lbug` and `meta.json` are left as they were. + */ +export async function writeBridge( + groupDir: string, + input: WriteBridgeInput, +): Promise { + return withGroupSyncLock(groupDir, () => writeBridgeUnlocked(groupDir, input)); +} + /* ------------------------------------------------------------------ */ /* openBridgeDbReadOnly */ /* ------------------------------------------------------------------ */ diff --git a/gitnexus/src/core/group/completeness.ts b/gitnexus/src/core/group/completeness.ts new file mode 100644 index 000000000..16aee0473 --- /dev/null +++ b/gitnexus/src/core/group/completeness.ts @@ -0,0 +1,169 @@ +/** + * The one computation of "is this cross-repo answer complete?" (KTD10), and the + * truncation vocabulary it speaks. + * + * A LEAF MODULE, deliberately, and that is the whole reason it exists apart from + * `cross-impact.ts`. Three surfaces need this fold — impact, trace, and the + * contract listing — but `cross-impact.ts` statically imports `bridge-db.ts`, + * and through it the native LadybugDB binding. `service.ts` therefore had to + * reach the fold through `await import('./cross-impact.js')`, which loaded that + * entire module graph on the first `group_contracts` of every process — 44-51ms + * and 8.4MB of RSS to run a `Set` union and a ternary, once per CLI invocation. + * + * 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, MatchType } from './types.js'; + +/** + * A union rather than `Pick` so the two states are + * distinguishable by their `truncated` discriminant: a caller that folds these + * fields into its own result (see `crossRepoCompleteness`) can then read + * `truncationReason` on the truncated branch without a fallback for a value + * that cannot be absent there. + */ +export type TruncationFields = + | { truncated: false } + | { + truncated: true; + truncationReason: GroupImpactTruncationReason; + riskEpistemic: 'lower-bound'; + }; + +/** + * Build the truncation fields every `runGroupImpact` return path shares. + * + * `riskEpistemic` must follow `truncated` mechanically: it is the marker that + * tells a caller the `risk` value is a floor rather than a verdict, and + * `mergeRisk` can only under-report once a crossing is dropped. Attaching it at + * each return let two of the four paths set `truncated` without it, so a + * truncated result read as complete — deriving it in one place is what keeps + * the invariant from drifting again (#2787). + */ +export function truncationFields( + truncated: boolean, + // Only read on the truncated branch, so the not-truncated call sites omit it + // rather than passing a reason that is thrown away. + reasonIfTruncated: GroupImpactTruncationReason = 'partial', +): TruncationFields { + if (!truncated) return { truncated: false }; + return { truncated: true, truncationReason: reasonIfTruncated, riskEpistemic: 'lower-bound' }; +} + +/** + * Everything a caller needs in order to say whether a cross-repo answer is + * complete — deliberately WITHOUT naming where any of it came from. + * + * `BridgeMeta` is not in this signature, and must not be: `groupContracts` + * answers the same question from `contracts.json` (via + * `loadContractRegistryResilient`) and never opens a bridge at all, so + * `version` / `repoListsUnreadable` / `pairedWithDatabase` do not exist on that + * path. Each caller computes its own `provenanceUnknown` from whatever + * provenance IT has and passes the boolean in. + */ +export interface CrossRepoCompletenessInput { + /** + * Repos the sync could not extract from, and repos it found no entry for. + * Two independent diagnostics with one consequence — none of those repos' + * contracts are in the artifact — so they are folded into one set. + */ + 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; + /** + * The query's DECLARED scope, not the set of repos the walk happened to + * reach: the subgroup filter for an impact query, the two endpoint repos for + * a trace, every member for a query that names none. An incomplete repo the + * caller never asked about cannot make the caller's answer a floor, and + * marking it anyway is how the marker stops meaning anything. Passing the + * predicate in — rather than a repo list, or a subgroup — is what keeps + * narrowing a scope a call-site change. + */ + inScope: (repoPath: string) => boolean; +} + +/** The structured triple, plus the in-scope repos that produced it. */ +export type CrossRepoCompleteness = TruncationFields & { + /** + * In-scope repos absent from the artifact, deduped, in first-seen order. + * Empty on a provenance-unknown answer: nothing was measured there, and + * inventing names out of an unreadable value is not a measurement. + */ + 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). + * + * Three surfaces can return a partial cross-repo answer — impact, trace, and + * the contract listing — and each used to decide for itself, in its own + * vocabulary, which is how two of them ended up saying it in prose only. The + * answer is the same structured triple `GroupImpactResult` already carries, so + * an agent reading any of them learns "complete" vs "floor" the same way. + * + * `truncationFields` derives `riskEpistemic` from `truncated` mechanically, and + * is reused here rather than re-implemented for the same reason it exists: the + * marker that says "this is a floor, not a verdict" may never drift away from + * the flag that says the answer was cut short (#2787). + */ +export function crossRepoCompleteness(input: CrossRepoCompletenessInput): CrossRepoCompleteness { + 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( + repoSideIncomplete || suppressed, + repoSideIncomplete ? 'incomplete-sync' : 'suppressed-stage', + ), + incompleteRepos, + }; +} + +/** + * A recorded repo list is an array of strings. Anything else — a bare string, an + * object, an array of objects — is a value we could not read, which is "not + * recorded", not "none". + * + * ONE definition, deliberately. This gate is the predicate the whole + * absent-vs-empty-vs-populated distinction rests on, and it applies to the same + * two lists on both the registry and the bridge metadata. It lived in two files + * verbatim, which meant tightening it — say, to reject blank strings — would + * have fixed one surface and silently left the other. + * + * `Array.isArray` alone is not enough: only an array of strings survives + * `cli/group.ts`'s `.join(', ')` as repo paths rather than as `[object Object]`. + */ +export function recordedRepoList(value: unknown): string[] | undefined { + if (!Array.isArray(value)) return undefined; + return value.every((entry) => typeof entry === 'string') ? (value as string[]) : undefined; +} diff --git a/gitnexus/src/core/group/config-parser.ts b/gitnexus/src/core/group/config-parser.ts index 29c868171..373e5e5c4 100644 --- a/gitnexus/src/core/group/config-parser.ts +++ b/gitnexus/src/core/group/config-parser.ts @@ -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; + const reposRaw = raw.repos as Record; + const repos: Record = {}; + 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) || {})) { + 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>) || {}; diff --git a/gitnexus/src/core/group/cross-impact.ts b/gitnexus/src/core/group/cross-impact.ts index 06c16d3fa..da9ba0de4 100644 --- a/gitnexus/src/core/group/cross-impact.ts +++ b/gitnexus/src/core/group/cross-impact.ts @@ -5,13 +5,14 @@ import fsp from 'node:fs/promises'; import path from 'node:path'; +import type { ImpactRisk } from 'gitnexus-shared'; import type { BridgeHandle, + BridgeMeta, ContractType, CrossRepoImpact, GroupConfig, GroupImpactResult, - GroupImpactTruncationReason, MatchType, OutOfScopeLink, } from './types.js'; @@ -24,12 +25,23 @@ import { } from './group-path-utils.js'; import { getGroupDir } from './storage.js'; import { + bridgeMetaMatchesFile, closeBridgeDb, getCachedBridgeReadOnly, queryBridge, readBridgeMeta, } from './bridge-db.js'; import { BRIDGE_SCHEMA_VERSION } from './bridge-schema.js'; +// Re-exported so the three surfaces keep one import site for the vocabulary, +// while the fold itself stays in a leaf module no native binding reaches. +export { + truncationFields, + crossRepoCompleteness, + type TruncationFields, + type CrossRepoCompleteness, + type CrossRepoCompletenessInput, +} from './completeness.js'; +import { truncationFields, crossRepoCompleteness } from './completeness.js'; import { compareCodeUnits } from '../../lib/utils.js'; // High limit for the local phase of group impact so collectImpactSymbolUids @@ -147,6 +159,15 @@ export function validateGroupImpactParams(params: Record): name: string; repoPath: string; target: string; + // Target selectors, same names/semantics as the single-repo impact tool + // (target_uid = zero-ambiguity lookup that wins over the name; + // file_path/kind narrow a name shared by same-named symbols). Threading + // them through HERE is what makes the MCP boundary's forwarding live — + // dropping them at this boundary silently re-broke the group-mode + // disambiguation loop once already. + target_uid?: string; + file_path?: string; + kind?: string; direction: 'upstream' | 'downstream'; maxDepth: number; crossDepth: number; @@ -161,11 +182,21 @@ export function validateGroupImpactParams(params: Record): | { ok: false; error: string } { const name = String(params.name ?? '').trim(); const repoPath = String(params.repo ?? '').trim(); - const target = String(params.target ?? '').trim(); + // Optional string, same helper shape as cross-trace's `str()`: empty/blank + // counts as absent so `target_uid: ''` degrades to the name lookup rather + // than a zero-ambiguity lookup of the empty uid. Parsed before the required + // check so UID-only callers (MCP impact schema requires `direction`, not + // `target`) are accepted. + const str = (v: unknown): string | undefined => + typeof v === 'string' && v.trim() !== '' ? v : undefined; + const targetName = String(params.target ?? '').trim(); + const target_uidEarly = str(params.target_uid); if (!name) return { ok: false, error: 'name is required' }; if (!repoPath) return { ok: false, error: 'repo is required (group repo path, e.g. app/backend)' }; - if (!target) return { ok: false, error: 'target is required' }; + if (!targetName && !target_uidEarly) + return { ok: false, error: 'target or target_uid is required' }; + const target = targetName || target_uidEarly!; if ( params.service !== undefined && params.service !== null && @@ -193,6 +224,10 @@ export function validateGroupImpactParams(params: Record): const service = normalizeServicePrefix(params.service); const subgroup = typeof params.subgroup === 'string' ? params.subgroup : undefined; + const target_uid = target_uidEarly; + const file_path = str(params.file_path); + const kind = str(params.kind); + // Clamp at the validate boundary so the downstream `deadline` (line // ~366) and `safeLocalImpact`'s `setTimeout` both see a single // bounded value. Without this, the outer deadline budgeted Phase-2 @@ -212,6 +247,9 @@ export function validateGroupImpactParams(params: Record): name, repoPath, target, + target_uid, + file_path, + kind, direction, maxDepth, crossDepth, @@ -232,6 +270,17 @@ async function resolveGroupRepo( ): Promise { 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 { @@ -370,7 +419,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'; @@ -380,24 +439,47 @@ export function mergeRisk(localRisk: string, cross: CrossRepoImpact[]): string { return localRisk; } +function liftLocalRiskMeta( + local: unknown, + cross: CrossRepoImpact[], +): Pick { + const { riskSharedAxes, riskScale } = local as { + riskSharedAxes?: unknown; + riskScale?: GroupImpactResult['riskScale']; + }; + return { + ...(riskSharedAxes !== undefined + ? { riskSharedAxes: mergeRisk(asImpactRisk(riskSharedAxes), cross) } + : {}), + ...(riskScale !== undefined ? { riskScale } : {}), + }; +} + /** - * Build the truncation fields every `runGroupImpact` return path shares. + * Is this bridge's metadata unable to say where its contents came from? * - * `riskEpistemic` must follow `truncated` mechanically: it is the marker that - * tells a caller the `risk` value is a floor rather than a verdict, and - * `mergeRisk` can only under-report once a crossing is dropped. Attaching it at - * each return let two of the four paths set `truncated` without it, so a - * truncated result read as complete — deriving it in one place is what keeps - * the invariant from drifting again (#2787). + * The three reads are all about a `BridgeMeta` and stay OUT of + * `crossRepoCompleteness` on purpose (see its doc): they are how a caller that + * opened a bridge computes `provenanceUnknown`, not how every caller does. + * + * - `version === 0` — no readable meta.json at all (`readBridgeMeta` answers + * that for both "absent" and "unparseable"); + * - `repoListsUnreadable` — a meta.json that parsed but whose repo lists are + * not repo lists. A value we could not read is not a measurement of zero, + * so it may not be spent as one; + * - `pairedWithDatabase === false` — a meta.json that does not describe the + * database sitting beside it, which is what a sync interrupted between the + * swap and the metadata write leaves behind. Measured by + * `ensureBridgeReady` BEFORE the database is opened and carried on the + * meta; this only reads the answer (#3012). + * + * Treating any of them as complete is the fail-open the completeness channel + * exists to close. */ -function truncationFields( - truncated: boolean, - // Only read on the truncated branch, so the not-truncated call sites omit it - // rather than passing a reason that is thrown away. - reasonIfTruncated: GroupImpactTruncationReason = 'partial', -): Pick { - if (!truncated) return { truncated: false }; - return { truncated: true, truncationReason: reasonIfTruncated, riskEpistemic: 'lower-bound' }; +export function bridgeProvenanceUnknown(meta: BridgeMeta): boolean { + return ( + meta.version === 0 || meta.repoListsUnreadable === true || meta.pairedWithDatabase === false + ); } function addCrossImpact(cross: CrossRepoImpact[], candidate: CrossRepoImpact): void { @@ -418,7 +500,7 @@ function addCrossImpact(cross: CrossRepoImpact[], candidate: CrossRepoImpact): v export async function ensureBridgeReady( groupDir: string, -): Promise<{ handle: BridgeHandle } | { error: string }> { +): Promise<{ handle: BridgeHandle; meta: BridgeMeta } | { error: string }> { const meta = await readBridgeMeta(groupDir); if (meta.version > 0 && meta.version !== BRIDGE_SCHEMA_VERSION) { return { @@ -433,6 +515,13 @@ export async function ensureBridgeReady( error: `No bridge.lbug in this group directory. Run gitnexus group sync (schema ${BRIDGE_SCHEMA_VERSION}).`, }; } + // Pair the metadata to the database BEFORE opening it, and carry the answer. + // An unstamped pair is judged on the two files' write order, so any open that + // touched `bridge.lbug`'s mtime would silently convert "legacy but intact" + // into "provenance unknown" for every pre-stamp bridge on that platform. This + // ordering removes the question rather than betting on the answer. + meta.pairedWithDatabase = await bridgeMetaMatchesFile(groupDir, meta); + // Use the cached read-only handle if available — avoids reopening the same // bridge.lbug in a long-lived MCP server, which fails on Windows because // the OS handle isn't fully released before the next open races in. @@ -442,7 +531,7 @@ export async function ensureBridgeReady( error: `Could not open bridge.lbug read-only (schema ${BRIDGE_SCHEMA_VERSION}). Run gitnexus group sync.`, }; } - return { handle }; + return { handle, meta }; } function rowToNeighbor(r: Record): BridgeNeighborRow | null { @@ -516,6 +605,9 @@ export async function runGroupImpact( name, repoPath, target, + target_uid, + file_path, + kind, direction, maxDepth, crossDepth: _crossDepth, @@ -543,6 +635,14 @@ export async function runGroupImpact( const impactParams: Parameters[1] = { target, + // Selector params pass through to the member repo's impact (the port + // contract in service.ts documents them), so the single-repo tool's + // "re-call with target_uid to disambiguate" loop works unchanged in + // group mode. `undefined` keeps the call shape flat — same convention + // as the relationTypes line below. + target_uid, + file_path, + kind, direction, maxDepth, relationTypes: relationTypes && relationTypes.length > 0 ? relationTypes : undefined, @@ -576,6 +676,7 @@ export async function runGroupImpact( cross_repo_hits: 0, }, risk: 'UNKNOWN', + ...liftLocalRiskMeta(local, []), timeoutMs, crossDepthWarning, }; @@ -631,7 +732,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, }; @@ -641,6 +743,25 @@ export async function runGroupImpact( if ('error' in bridgePrep) return { error: bridgePrep.error }; const handle = bridgePrep.handle; + // Repos the sync that built this bridge could not account for. Their + // contracts — and every cross-link touching them — are simply absent from + // bridge.lbug, and nothing else in this walk can notice that: the only + // incompleteness channel on the result is `truncationFields`, driven by + // fan-out state. Without folding these in, a query about a symbol whose one + // downstream consumer lives in an unreadable repo returns + // `{ cross: [], truncated: false }` — "complete: nothing depends on this" — + // which is a wrong answer, not an empty one, for a tool an agent uses to + // license a delete or a rename. + // + // The metadata read that answers it (`bridgeProvenanceUnknown`) happens + // INSIDE the `try` below, and the flag is initialized fail-closed here only + // because it outlives that block. The lease taken by `ensureBridgeReady` is + // released by the `finally` and nowhere else, so work done between the lease + // and the `try` is work whose every throw leaks a refcount the cached handle + // can never get back — which is how a malformed meta.json used to wedge the + // handle as well as crash the query. (The repo lists are folded in after the + // `finally`, where a throw can no longer strand the lease.) + let provenanceUnknown = true; const cross: CrossRepoImpact[] = []; const outOfScope: OutOfScopeLink[] = []; const truncatedRepos: string[] = []; @@ -650,6 +771,8 @@ export async function runGroupImpact( let fanoutTimedOut = false; try { + provenanceUnknown = bridgeProvenanceUnknown(bridgePrep.meta); + const neighbors = await resolveBridgeNeighbors(handle, { localRepo: repoPath, uids, @@ -780,9 +903,48 @@ export async function runGroupImpact( } const localSum = (local as { summary?: Record })?.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); - const truncated = truncatedRepos.length > 0 || localPartial; + // The bridge's own incompleteness, in the shared vocabulary, read through + // what this query DECLARED. The fan-out above already drops every neighbour + // outside `subgroup`, so an incomplete repo the query excluded could not have + // contributed a crossing to this answer — marking the answer a floor because + // of it makes the marker fire on results it does not describe, which is how a + // caller learns to ignore it. An unscoped query passes `subgroup: undefined`, + // which `repoInSubgroup` answers true for, so the intersection is the whole + // set and that path is byte-for-byte the old behaviour. + // + // The declared scope is the subgroup PLUS the query's own repo (`exact` + // reuses the one membership helper for the equality, rather than growing a + // second notion of it): the walk starts from `repoPath`'s contracts in the + // bridge, so if THAT is the repo the sync could not read there are no + // crossings to find for any scope, and a subgroup excluding it must not turn + // that vacuum into a confident "complete". + // + // Declared scope, not traversed scope: an incomplete repo's contracts are + // absent from the bridge by definition, so it is never in the set the walk + // reached — filtering on what was traversed would empty the intersection on + // every query and silently restore the fail-open. + // + // Sound only while `MAX_SUPPORTED_CROSS_DEPTH` is 1. At depth 2+ an + // out-of-scope repo can sit BETWEEN two in-scope ones, so dropping it would + // convert a genuine lower bound into a confident complete answer; widen this + // predicate in the same change that raises the depth. + 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), + }); + // One predicate, read twice below. Written out at both sites, a third runtime + // cause added to the flag and forgotten at the reason would label a + // retry-able answer `incomplete-sync` — telling the operator to re-sync for + // something a retry fixes. That reason-vs-flag drift is what `truncationFields` + // exists to prevent. + const runtimeTruncated = truncatedRepos.length > 0 || localPartial; + const truncated = runtimeTruncated || bridge.truncated; const result: GroupImpactResult = { local, @@ -794,8 +956,25 @@ 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. - ...truncationFields(truncated, fanoutTimedOut ? 'timeout' : 'partial'), - truncatedRepos: [...new Set(truncatedRepos)], + // 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' + : bridge.truncated + ? bridge.truncationReason + : 'incomplete-sync', + ), + truncatedRepos: [...new Set([...truncatedRepos, ...bridge.incompleteRepos])], summary: { direct: localSum.direct ?? 0, processes_affected: localSum.processes_affected ?? 0, @@ -803,6 +982,7 @@ export async function runGroupImpact( cross_repo_hits: cross.length, }, risk: mergeRisk(localRisk, cross), + ...liftLocalRiskMeta(local, cross), timeoutMs, crossDepthWarning, }; diff --git a/gitnexus/src/core/group/cross-trace.ts b/gitnexus/src/core/group/cross-trace.ts index 7c115de01..92c52e789 100644 --- a/gitnexus/src/core/group/cross-trace.ts +++ b/gitnexus/src/core/group/cross-trace.ts @@ -25,16 +25,29 @@ import { GroupNotFoundError, loadGroupConfig } from './config-parser.js'; import { getGroupDir } from './storage.js'; -import { ensureBridgeReady, MAX_SUPPORTED_CROSS_DEPTH } from './cross-impact.js'; +import { + bridgeProvenanceUnknown, + crossRepoCompleteness, + ensureBridgeReady, + MAX_SUPPORTED_CROSS_DEPTH, +} from './cross-impact.js'; +import type { CrossRepoCompleteness } from './completeness.js'; +import { truncationFields } from './completeness.js'; import { compareCodeUnits } from '../../lib/utils.js'; import { closeBridgeDb, queryBridge } from './bridge-db.js'; +import { repoInSubgroup } from './group-path-utils.js'; import type { GroupPdgFlowHop, GroupRepoHandle, GroupSymbolResolution, GroupToolPort, } from './service.js'; -import type { BridgeHandle, GroupConfig } from './types.js'; +import type { + BridgeHandle, + BridgeMeta, + GroupConfig, + GroupImpactTruncationReason, +} from './types.js'; // ── Result types (discriminated on `status`) ───────────────────────────── @@ -77,7 +90,29 @@ export interface GroupTraceEndpoint { repo: string; } -export interface GroupTraceOkResult { +/** + * The incompleteness vocabulary, verbatim from `GroupImpactResult` (KTD10). + * + * A cross-repo trace and a cross-repo impact can both be cut short by the same + * two kinds of cause — a runtime limit inside this walk, or a bridge that never + * held part of the group — and an agent must not have to learn a second + * vocabulary (or parse a `notes` string) to tell "no path exists" from "we + * could not have seen the path". Every field here means exactly what it means + * on `GroupImpactResult`; `notes` stays a human-readable ADDITION to them, + * never the machine-readable channel. + */ +export interface GroupTraceCompleteness { + /** True when this answer is a floor rather than a verdict. */ + truncated?: boolean; + /** Why, when `truncated` — runtime limit ('partial'/'timeout') before structure. */ + truncationReason?: GroupImpactTruncationReason; + /** Set with `truncated`: the answer under-reports, it never over-reports. */ + riskEpistemic?: 'lower-bound'; + /** In-scope repos absent from the bridge; omitted when none were measured. */ + truncatedRepos?: string[]; +} + +export interface GroupTraceOkResult extends GroupTraceCompleteness { status: 'ok'; group: string; from: GroupTraceEndpoint; @@ -89,7 +124,6 @@ export interface GroupTraceOkResult { edges: TraceEdge[]; /** Present only when PDG enrichment ran for at least one segment. */ dataFlow?: SegmentDataFlow[]; - truncated?: boolean; notes: string[]; } @@ -101,23 +135,23 @@ export interface GroupTraceCandidate { startLine: number; } -export interface GroupTraceNotFoundResult { +/** + * `truncated: true` here means the answer is NOT authoritative — either the + * crossing cap (`MAX_CROSSINGS_TO_TRY`) was hit so a connecting ContractLink + * ranked beyond it may have been skipped, or the bridge itself never held part + * of the group. Both read as "unknown", not as "no path exists"; + * `truncationReason` says which. + */ +export interface GroupTraceNotFoundResult extends GroupTraceCompleteness { status: 'not_found'; group: string; role?: 'from' | 'to'; query?: string; - /** - * True when the answer is NOT authoritative: the crossing cap - * (`MAX_CROSSINGS_TO_TRY`) was hit, so a connecting ContractLink ranked beyond - * the cap may have been skipped. A consumer should treat this as "unknown", - * not "no path exists". - */ - truncated?: boolean; notes: string[]; suggestion?: string; } -export interface GroupTraceAmbiguousResult { +export interface GroupTraceAmbiguousResult extends GroupTraceCompleteness { status: 'ambiguous'; group: string; role: 'from' | 'to'; @@ -187,6 +221,55 @@ export const TRACE_NOTES = { 'The candidates are listed; trace from the exact calling function or pass `to_uid`.', } as const; +/** + * Fold this bridge's completeness into the runtime-truncation flag a trace call + * site already computed, and answer in the shared vocabulary. + * + * Precedence mirrors `runGroupImpact`: a runtime limit wins the reason, because + * it is the cause the caller can act on (narrow the query, raise maxDepth), + * while `'incomplete-sync'` needs a different remedy — `gitnexus group sync` — + * and would otherwise mask it. + * + * Returns `{}` — not `{ truncated: false }` — when the answer is complete, so a + * clean trace result keeps the exact shape it has always had. + */ +function traceCompleteness( + bridge: CrossRepoCompleteness, + runtimeTruncated: boolean, +): GroupTraceCompleteness { + const repos = bridge.incompleteRepos.length > 0 ? { truncatedRepos: bridge.incompleteRepos } : {}; + // Through `truncationFields`, not hand-written: `riskEpistemic` must follow + // `truncated` mechanically, and a third writer of that pair is how the + // invariant drifts (#2787). The bridge branch re-spreads the helper's own + // output rather than naming its fields. + if (runtimeTruncated) return { ...truncationFields(true, 'partial'), ...repos }; + if (!bridge.truncated) return {}; + const { incompleteRepos: _incompleteRepos, ...fields } = bridge; + return { ...fields, ...repos }; +} + +/** + * The trace's declared scope for `crossRepoCompleteness`. + * + * A symbol-to-symbol trace asks about exactly two repos, so an unreadable third + * member cannot make its answer a floor. A DESTINATION trace declares no `to` + * at all — the call may land in any member — so every repo is in scope there, + * which is why the predicate is built per call site rather than derived from + * the endpoints inside the helper. + */ +function bridgeCompletenessFor( + meta: BridgeMeta, + inScope: (repoPath: string) => boolean, +): CrossRepoCompleteness { + return crossRepoCompleteness({ + unreadableRepos: meta.unreadableRepos, + missingRepos: meta.missingRepos, + suppressedMatchStages: meta.suppressedMatchStages, + provenanceUnknown: bridgeProvenanceUnknown(meta), + inScope, + }); +} + /** Repo-relative path equality, tolerant of a leading "./" / "/" or a repo prefix. */ function sameFile(a: string, b: string): boolean { if (!a || !b) return false; @@ -873,6 +956,23 @@ async function stitchCrossRepo( if (p.pdg) notes.push(TRACE_NOTES.pdgRequested); try { + // Inside the `try`, like `runGroupImpact`'s equivalent: the lease taken by + // `ensureBridgeReady` is released by this block's `finally` and nowhere + // else, so anything computed between the lease and the `try` is work whose + // every throw would strand a refcount the cached handle never gets back. + // + // Declared scope = the two endpoint repos. Whether either of them is a repo + // this bridge could not read decides whether "no ContractLink connects + // them" is a verdict or a floor. + const bridge = bridgeCompletenessFor( + bridgePrep.meta, + // `repoInSubgroup(..., exact)` rather than `===`: it normalizes separators + // and strips trailing slashes, which bare equality does not, so the same + // group.yaml spelling cannot be in scope for impact and out of scope here. + (repoPath) => + repoInSubgroup(repoPath, fromEp.member.repoPath, true) || + repoInSubgroup(repoPath, toEp.member.repoPath, true), + ); const { crossings, truncated: crossingsTruncated } = await listCrossingsBetween( handle, fromEp.member.repoPath, @@ -883,6 +983,10 @@ async function stitchCrossRepo( return { status: 'not_found', group: p.name, + // No crossings at all is exactly the answer a bridge that never held an + // endpoint's repo produces, so it is the one that most needs the floor + // marker. (Nothing was capped: there were zero rows to cap.) + ...traceCompleteness(bridge, false), notes, suggestion: 'The endpoints live in different repos with no ContractLink between them. ' + @@ -1016,6 +1120,13 @@ async function stitchCrossRepo( hopCount: edges.length, hops: [...hopsA, ...hopsB], edges, + // A found path is still an answer from this bridge: if its provenance is + // unknown, or an endpoint's repo never made it in, the path may be stale + // and it is certainly not the only one. An incompleteness channel that + // fires only on the empty answer teaches an agent that a non-empty one + // is always complete. The crossing cap is NOT folded in here — a path + // that connected is not a capped search — so this site passes `false`. + ...traceCompleteness(bridge, false), notes, ...(dataFlow.length > 0 ? { dataFlow } : {}), }; @@ -1028,7 +1139,7 @@ async function stitchCrossRepo( return { status: 'not_found', group: p.name, - ...(crossingsTruncated ? { truncated: true } : {}), + ...traceCompleteness(bridge, crossingsTruncated), notes, suggestion: crossingsTruncated ? `No connecting crossing among the ${MAX_CROSSINGS_TO_TRY} highest-confidence ` + @@ -1099,6 +1210,12 @@ async function stitchToDestination( if (p.crossDepthClamped) notes.push(TRACE_NOTES.crossDepthClamped); try { + // Inside the `try` for the lease reason above `stitchCrossRepo`'s copy. A + // destination trace declares NO `to`: the call may land in any member, so + // every repo is in the query's scope and no incomplete one can be filtered + // out. An unreadable provider repo is precisely how "no outgoing + // ContractLink leaves this repo" becomes a wrong answer, not an empty one. + const bridge = bridgeCompletenessFor(bridgePrep.meta, () => true); const { crossings, truncated } = await listCrossingsFrom(handle, fromEp.member.repoPath); if (crossings.length === 0) { notes.push(TRACE_NOTES.destinationNoLink); @@ -1107,6 +1224,8 @@ async function stitchToDestination( group: p.name, role: 'to', query: p.from_uid ?? p.from, + // Zero rows to cap, so only the bridge's own completeness can speak. + ...traceCompleteness(bridge, false), notes, suggestion: 'Pass a `to` symbol for a symbol-to-symbol trace, or run group_sync.', }; @@ -1224,7 +1343,9 @@ async function stitchToDestination( hopCount: edgesA.length + 1, hops: [...hopsA, providerHop], edges: [...edgesA, boundaryEdge], - ...(truncated ? { truncated: true } : {}), + // The cap already marked this result; the bridge's completeness folds + // into the same fields rather than beside them. + ...traceCompleteness(bridge, truncated), notes: resultNotes, }; }; @@ -1240,6 +1361,8 @@ async function stitchToDestination( group: p.name, role: 'to', candidates: candidatesFrom(precise), + // The candidate LIST is what an incomplete bridge shortens here. + ...traceCompleteness(bridge, truncated), notes: [...notes, TRACE_NOTES.destinationMultiple], }; } @@ -1255,6 +1378,7 @@ async function stitchToDestination( group: p.name, role: 'to', candidates: candidatesFrom(fileLevel), + ...traceCompleteness(bridge, truncated), notes: [...notes, TRACE_NOTES.destinationAmbiguousFile], }; } @@ -1265,7 +1389,7 @@ async function stitchToDestination( group: p.name, role: 'to', query: p.from_uid ?? p.from, - ...(truncated ? { truncated: true } : {}), + ...traceCompleteness(bridge, truncated), notes, suggestion: 'Trace from the function that issues the HTTP request, or pass a `to` symbol.', }; diff --git a/gitnexus/src/core/group/extractors/fs-utils.ts b/gitnexus/src/core/group/extractors/fs-utils.ts index 384f63203..7f02bbd1d 100644 --- a/gitnexus/src/core/group/extractors/fs-utils.ts +++ b/gitnexus/src/core/group/extractors/fs-utils.ts @@ -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 { + 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((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; + } +} diff --git a/gitnexus/src/core/group/extractors/graphql-extractor.ts b/gitnexus/src/core/group/extractors/graphql-extractor.ts new file mode 100644 index 000000000..efafe4e2e --- /dev/null +++ b/gitnexus/src/core/group/extractors/graphql-extractor.ts @@ -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; + resolvers: Set; +} + +type DecoratorFieldName = + | { kind: 'absent' } + | { kind: 'literal'; value: string } + | { kind: 'dynamic' }; + +type GeneratedSymbolIndex = Map; +type GeneratedIndexCache = Map>; + +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, key: string, position: number): string { + return String(row[key] ?? row[position] ?? ''); +} + +function uniqueRealSymbol(rows: Record[]): 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, +): Set | null { + const fields = new Set(); + if (!selectionSet) return null; + const seenFragments = new Set(); + 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(); + 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(); + 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(); + const resolvers = new Set(); + 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, +): { 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, +): 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[] | null { + const fields: string[] = []; + const seenFragments = new Set(); + 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 { + 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 { + 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(); + 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 { + return true; + } + + async extract( + dbExecutor: CypherExecutor | null, + repoPath: string, + _repo: RepoHandle, + ): Promise { + 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 { + 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 { + 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(); + 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; + } +} diff --git a/gitnexus/src/core/group/extractors/http-patterns/java.ts b/gitnexus/src/core/group/extractors/http-patterns/java.ts index 4eba0c1f1..8b8897a91 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/java.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/java.ts @@ -11,6 +11,7 @@ import { intersectSpringHttpMethods, isRouteMemberKey, findEnclosingClass, + isClassLevelMappingAnnotation, joinPath, type SharedSpringType, } from '../../../ingestion/route-extractors/spring-shared.js'; @@ -28,6 +29,16 @@ import { REQUEST_LINE_CONFIDENCE, EXCHANGE_CONFIDENCE, } from './spring-consumer-shared.js'; +import { + expandJavaWildcardStaticImports, + extractJavaModuleConstants, + foldJavaOperands, + isJavaConstantFile, + parseJavaConstOperands, + prepareJavaRouteConstants, + type JavaConstantIndex, + type RepoConstants, +} from '../../../ingestion/route-extractors/java-const-resolver.js'; import { extractStaticPathExpression, inferOkHttpMethod, @@ -165,6 +176,34 @@ const JAVA_ROUTE_ANNOTATION_PATTERNS = compilePatterns({ key: (identifier) @key value: [(string_literal) @value (element_value_array_initializer (string_literal) @value)])))) name: (identifier) @member) @node + (class_declaration + (modifiers + (annotation + name: [(identifier) (scoped_identifier)] @ann + arguments: (annotation_argument_list [(identifier) @value_expr (field_access) @value_expr (binary_expression) @value_expr])))) @node + (class_declaration + (modifiers + (annotation + name: [(identifier) (scoped_identifier)] @ann + arguments: (annotation_argument_list + (element_value_pair + key: (identifier) @key + value: [(identifier) @value_expr (field_access) @value_expr (binary_expression) @value_expr]))))) @node + (method_declaration + (modifiers + (annotation + name: [(identifier) (scoped_identifier)] @ann + arguments: (annotation_argument_list [(identifier) @value_expr (field_access) @value_expr (binary_expression) @value_expr]))) + name: (identifier) @member) @node + (method_declaration + (modifiers + (annotation + name: [(identifier) (scoped_identifier)] @ann + arguments: (annotation_argument_list + (element_value_pair + key: (identifier) @key + value: [(identifier) @value_expr (field_access) @value_expr (binary_expression) @value_expr])))) + name: (identifier) @member) @node ] `, }, @@ -429,13 +468,15 @@ function annotationHasRouteMember(annotation: Parser.SyntaxNode): boolean { } function typeRequestMethods(typeNode: Parser.SyntaxNode): readonly string[] { - const mappings = declarationAnnotations(typeNode).filter( - (annotation) => - simpleName(annotation.childForFieldName('name')?.text ?? '') === 'RequestMapping', + const mappings = declarationAnnotations(typeNode).filter((annotation) => + isClassLevelMappingAnnotation(simpleName(annotation.childForFieldName('name')?.text ?? '')), ); if (mappings.length === 0) return ['*']; if (mappings.length !== 1) return []; - return springAnnotationHttpMethods('RequestMapping', mappings[0].text); + return springAnnotationHttpMethods( + simpleName(mappings[0].childForFieldName('name')?.text ?? 'RequestMapping'), + mappings[0].text, + ); } function hasAnnotation(node: Parser.SyntaxNode, names: string | readonly string[]): boolean { @@ -469,6 +510,12 @@ interface MethodRouteAnnotation { rawPath: string; /** OpenFeign's single effective verb; null means its contract is invalid/ambiguous. */ feignHttpMethod?: string | null; + /** + * Non-literal path operands (constant ref or `+`-concat), captured when the + * annotation value is not a string literal. Resolved against the repo-wide + * Java constant map in scan(); a failed fold drops the route (skip floor). + */ + pathOperands?: readonly import('../../../ingestion/route-extractors/constant-resolver.js').Operand[]; } interface RequestLineAnnotation { @@ -484,6 +531,16 @@ interface RouteAnnotationScan { feignPrefixByInterfaceId: Map; /** Spring HTTP Interface `@HttpExchange(url|value)` type-level prefixes per class/interface node id. */ httpExchangePrefixByTypeId: Map; + /** + * Class node ids whose `@RequestMapping` prefix is a constant reference or + * concat rather than a literal. Folding a TYPE-level prefix would need the + * repo constant map inside `scanRouteAnnotations`, which has no access to it, + * so `scan()` suppresses every method route under such a class instead of + * emitting it with the prefix silently dropped (a wrong path, not a missing + * one). Ingestion's `extractSpringRoutes` applies the identical rule — R4 + * parity. + */ + typesWithUnfoldablePrefix: Set; /** Resolved Spring shortcut/`@RequestMapping` routes — paths × verbs yield one entry each. */ methodRoutes: MethodRouteAnnotation[]; /** One entry per OpenFeign `@RequestLine` whose value parses to a verb + path. */ @@ -511,6 +568,7 @@ function scanRouteAnnotations(tree: Parser.Tree): RouteAnnotationScan { // feeds the OpenFeign *consumer* path in scan(). An interface carrying both // `@RequestMapping` and `@FeignClient(path)` lands a different value in each. const prefixByTypeId = new Map(); + const typesWithUnfoldablePrefix = new Set(); const feignPrefixByInterfaceId = new Map(); const httpExchangePrefixByTypeId = new Map(); const methodRoutes: MethodRouteAnnotation[] = []; @@ -527,7 +585,10 @@ function scanRouteAnnotations(tree: Parser.Tree): RouteAnnotationScan { const annNode = captures.ann; const node = captures.node; const valueNode = captures.value; - if (!annNode || !node || !valueNode) continue; + // A non-literal annotation value (constant ref / `+`-concat) is captured + // as @value_expr instead of @value — one of the two must be present. + const valueExprNode = captures.value_expr; + if (!annNode || !node || (!valueNode && !valueExprNode)) continue; // Discrimination is on the trailing segment only (`simpleName`), so a // non-Spring annotation whose last segment collides with a route annotation // (e.g. `@com.evil.GetMapping("/x")`) is treated as a route. This is the @@ -550,7 +611,7 @@ function scanRouteAnnotations(tree: Parser.Tree): RouteAnnotationScan { const feignHttpMethod = httpMethods.length === 1 ? (httpMethods[0] === '*' ? 'GET' : httpMethods[0]) : null; if (!isRouteMemberKey(keyNode)) continue; - const rawPath = unquoteLiteral(valueNode.text); + const rawPath = valueNode ? unquoteLiteral(valueNode.text) : null; if (rawPath !== null) { for (const httpMethod of httpMethods) { methodRoutes.push({ @@ -561,10 +622,33 @@ function scanRouteAnnotations(tree: Parser.Tree): RouteAnnotationScan { feignHttpMethod, }); } + } else { + // Non-literal path (a constant reference or `+`-concatenation). + // Defer to scan(): the fold needs the repo-wide constant map built + // by prepareRepo. Capture the operand list now; resolution happens + // in scan() against JavaRepoContext, and an unresolvable operand + // list leaves the route skipped (KTD5 skip floor). + const operands = parseJavaConstOperands(valueExprNode); + if (operands !== null) { + for (const httpMethod of httpMethods) { + methodRoutes.push({ + methodNode: node, + methodName: captures.member?.text ?? null, + httpMethod, + rawPath: '', + feignHttpMethod, + pathOperands: operands, + }); + } + } } } else if (ann === 'RequestLine') { // Feign packs verb + path in one literal; its only named argument is `value`. if (keyNode && keyNode.text !== 'value') continue; + // A constant-valued `@RequestLine` arrives as @value_expr, not @value — + // `valueNode` is undefined in that shape. Skip rather than dereference + // (constant folding for Feign verb+path literals is out of scope here). + if (!valueNode) continue; const raw = unquoteLiteral(valueNode.text); const parsed = raw !== null ? parseRequestLine(raw) : null; if (parsed) { @@ -579,7 +663,7 @@ function scanRouteAnnotations(tree: Parser.Tree): RouteAnnotationScan { // `url` or `value` attribute (or positionally); other attributes // (`accept`, `contentType`, …) are not routes. if (keyNode && keyNode.text !== 'url' && keyNode.text !== 'value') continue; - const rawPath = unquoteLiteral(valueNode.text); + const rawPath = valueNode ? unquoteLiteral(valueNode.text) : null; if (rawPath !== null) { exchangeRoutes.push({ methodNode: node, @@ -594,8 +678,13 @@ function scanRouteAnnotations(tree: Parser.Tree): RouteAnnotationScan { // Type-level (class or interface): a Spring `@RequestMapping` URL prefix, or // — on an interface — an OpenFeign `@FeignClient(path = "...")` prefix. - if (ann === 'RequestMapping') { + if (isClassLevelMappingAnnotation(ann)) { if (!isRouteMemberKey(keyNode)) continue; + if (!valueNode) { + // Constant-valued class prefix — see `typesWithUnfoldablePrefix`. + typesWithUnfoldablePrefix.add(node.id); + continue; + } const prefix = unquoteLiteral(valueNode.text); if (prefix !== null) { pushPrefix(prefixByTypeId, node.id, prefix); @@ -606,13 +695,13 @@ function scanRouteAnnotations(tree: Parser.Tree): RouteAnnotationScan { } else if (ann === 'FeignClient' && node.type === 'interface_declaration') { // Feign's `name`/`value` identify a service, not a path — only `path` is a prefix. if (!keyNode || keyNode.text !== 'path') continue; - const prefix = unquoteLiteral(valueNode.text); + const prefix = valueNode ? unquoteLiteral(valueNode.text) : null; if (prefix !== null) pushPrefix(feignPrefixByInterfaceId, node.id, prefix); } else if (ann === 'HttpExchange') { // Spring HTTP Interface type-level prefix: the path lives in `url`/`value` // (or positionally). Applies to its `@(Get|...)Exchange` consumer methods. if (keyNode && keyNode.text !== 'url' && keyNode.text !== 'value') continue; - const prefix = unquoteLiteral(valueNode.text); + const prefix = valueNode ? unquoteLiteral(valueNode.text) : null; if (prefix !== null) pushPrefix(httpExchangePrefixByTypeId, node.id, prefix); } } @@ -662,6 +751,7 @@ function scanRouteAnnotations(tree: Parser.Tree): RouteAnnotationScan { return { prefixByTypeId, + typesWithUnfoldablePrefix, feignPrefixByInterfaceId, httpExchangePrefixByTypeId, methodRoutes: constrainedMethodRoutes, @@ -707,9 +797,20 @@ function collectImplementedInterfaces(typeNode: Parser.SyntaxNode): string[] { } function collectSpringTypes(filePath: string, tree: Parser.Tree): SharedSpringType[] { - const { prefixByTypeId, methodRoutes } = scanRouteAnnotations(tree); + const { prefixByTypeId, typesWithUnfoldablePrefix, methodRoutes } = scanRouteAnnotations(tree); const routesByMethodId = new Map>(); for (const route of methodRoutes) { + // Constant-valued class prefix: no single prefix string exists here, so the + // inheritance view would publish this route unprefixed. Skip — same rule as + // scan() and as ingestion (R4 parity). + const owner = findEnclosingClass(route.methodNode); + if (owner && typesWithUnfoldablePrefix.has(owner.id)) continue; + // A constant-referencing route still carries `rawPath: ''` here — folding + // happens in scan() against the repo constant map, which this + // inheritance-view collector has no access to. Emitting it as an empty + // path would publish `POST /`-shaped noise into the shared type view; + // skip instead (ingestion keeps the same skip floor — R4 parity). + if (route.pathOperands) continue; const routes = routesByMethodId.get(route.methodNode.id) ?? []; routes.push({ method: route.httpMethod, path: route.rawPath }); routesByMethodId.set(route.methodNode.id, routes); @@ -781,8 +882,76 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = { content, ); }, - scan(tree) { + prepareRepo(args) { + // Build the repo-wide Java string-constant map once per extract() run + // (mirrors the Python binding's cost-gated pre-pass). A cheap content + // gate keeps literal-only repos at zero parses: only files containing a + // `static final String` declaration are parsed for constants. + try { + // The orchestrator hands over a bare Parser (no language set yet); + // bind Java explicitly — Python's prepareRepo does the same — otherwise + // parseSourceSafe spins to its 15 s budget per file. + args.parser.setLanguage(Java); + } catch { + // fall through: a parser that rejects binding cannot produce a constant + // map; per-file try/catch below then skips everything harmlessly. + } + const constants = new Map< + string, + import('../../../ingestion/route-extractors/constant-resolver.js').ModuleConstants + >(); + for (const rel of args.files) { + if (!rel.endsWith('.java')) continue; + try { + const src = args.readFile(rel); + // Cheap content gate: only constant-DEFINITION candidates get parsed + // here (~hundreds of files). Import-only files (every controller) + // are deliberately NOT parsed in this pass — scan() lazily extracts + // the importing file's own import table from the tree it already + // holds when a constant-referencing route actually needs the fold. + // A gate that also matched `import ...;` would parse the entire + // repository here (tens of thousands of files) just to build import + // tables the fold can derive per-file on demand. + // + // The predicate is the SHARED one the ingestion provider uses, so the + // two subsystems agree on which files define constants. Its previous + // local spelling missed `final static String` and lowercase interface + // names, and admitted an interface that ingestion's gate rejected. + if (!src || !isJavaConstantFile(src)) { + continue; + } + const tree = args.parseSource(args.parser, src); + if (!tree) continue; + const mc = extractJavaModuleConstants(tree); + if ( + mc.literals.size > 0 || + mc.exprs.size > 0 || + mc.imports.size > 0 || + (mc.wildcardImports?.length ?? 0) > 0 + ) { + constants.set(rel, mc); + } + } catch { + // Per-file resilience: one unreadable/oversized/ill-formed file must + // not forfeit the whole repo's constant map (a missing constants + // class only degrades refs that pointed at it). + continue; + } + } + // On-demand static imports (`import static a.b.C.*`) were recorded as + // pending class FQNs during extraction; materialize their bare-name + // bindings now that the whole map exists. A wildcard's target is itself + // a constants file, so it is necessarily a map entry — anything else + // degrades to the fold's skip floor. In-place: each entry is owned by + // this map, and every file is expanded exactly once. + const constantIndex = prepareJavaRouteConstants(constants); + return { constants, constantIndex }; + }, + scan(tree, repoContext, fileRel) { const out: HttpDetection[] = []; + const javaCtx = repoContext as + | { constants: RepoConstants; constantIndex: JavaConstantIndex } + | undefined; // ─── Spring providers + OpenFeign consumers (one query pass) ──── // `scanRouteAnnotations` resolves every route-defining annotation — @@ -790,6 +959,7 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = { // `@RequestLine`s — from a single `matches()` pass over the tree. const { prefixByTypeId, + typesWithUnfoldablePrefix, feignPrefixByInterfaceId, httpExchangePrefixByTypeId, methodRoutes, @@ -802,7 +972,52 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = { // class is a Spring *provider*. A mapping on a non-Feign interface has no // enclosing class and is dropped here — interface→controller inheritance is // handled by `scanProject`. + // Lazy per-file constants view. prepareRepo only indexes constant- + // DEFINING files (cheap gate); an importing controller is absent from + // that map. When a route actually references a constant, extract THIS + // file's import table from the tree scan() already holds (zero extra + // parses) and overlay it for the fold. Files whose routes are all + // literal — the overwhelming majority — never pay this cost. + let foldConstants: RepoConstants | undefined; + const getFoldConstants = (): RepoConstants | undefined => { + if (foldConstants !== undefined) return foldConstants; + foldConstants = javaCtx?.constants; + if (!javaCtx?.constants || !fileRel) return foldConstants; + if (javaCtx.constants.has(fileRel)) return foldConstants; + try { + const mc = extractJavaModuleConstants(tree); + // A file carrying ONLY wildcard static imports has an empty import + // table pre-expansion — overlay it too, then materialize the promised + // bindings against the repo map before it becomes a fold target. + if (mc.imports.size > 0 || (mc.wildcardImports?.length ?? 0) > 0) { + const merged = new Map(javaCtx.constants); + expandJavaWildcardStaticImports(mc, fileRel, merged, javaCtx.constantIndex); + merged.set(fileRel, mc); + foldConstants = merged; + } + } catch { + // fold falls back to the repo-wide map (imports stay unresolved) + } + return foldConstants; + }; + for (const route of methodRoutes) { + // A constant-valued CLASS prefix cannot be folded here, so every method + // route under such a class is suppressed rather than emitted at a wrong + // (unprefixed) path — the same rule `classesWithArrayPrefix` already + // encodes for the array form, and the same rule ingestion applies. + const owner = findEnclosingClass(route.methodNode); + if (owner && typesWithUnfoldablePrefix.has(owner.id)) continue; + // Non-literal route path: fold the operand list against the repo-wide + // constant map. Skip (never a guessed path) when the fold fails or the + // repo context is absent (context-less fallback scanning). + if (route.pathOperands && javaCtx && fileRel) { + const resolved = foldJavaOperands(fileRel, route.pathOperands, getFoldConstants()!); + if (resolved === null) continue; + route.rawPath = resolved; + } else if (route.pathOperands) { + continue; + } const enclosingInterface = findEnclosingInterface(route.methodNode); if (enclosingInterface && hasAnnotation(enclosingInterface, 'FeignClient')) { if (!route.feignHttpMethod) continue; diff --git a/gitnexus/src/core/group/extractors/http-patterns/kotlin.ts b/gitnexus/src/core/group/extractors/http-patterns/kotlin.ts index 10f195111..b00a3e400 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/kotlin.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/kotlin.ts @@ -13,11 +13,26 @@ import type { HttpScanInput, } from './types.js'; import { - METHOD_ANNOTATION_TO_HTTP, findEnclosingClass, + intersectSpringHttpMethods, + isClassLevelMappingAnnotation, joinPath, + springAnnotationHttpMethods, 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 +57,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 +164,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 = 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 + @@ -243,6 +450,13 @@ function inferKotlinOkHttpMethod(urlCall: Parser.SyntaxNode): string | null { return name === null ? 'GET' : name.toUpperCase(); } +function enclosingAnnotationText(node: Parser.SyntaxNode): string { + for (let current: Parser.SyntaxNode | null = node; current; current = current.parent) { + if (current.type === 'annotation') return current.text; + } + return node.text; +} + /** * Build the plugin only if the Kotlin grammar is available. Compiling * the queries against a null grammar would throw at module load time @@ -280,7 +494,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { (modifiers (annotation (constructor_invocation - (user_type (type_identifier) @ann (#eq? @ann "RequestMapping")) + (user_type (type_identifier) @ann (#match? @ann "RequestMapping$")) (value_arguments (value_argument . [(string_literal) @prefix (collection_literal (string_literal) @prefix)]))))) (type_identifier) @cls) @class @@ -293,7 +507,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { (modifiers (annotation (constructor_invocation - (user_type (type_identifier) @ann (#eq? @ann "RequestMapping")) + (user_type (type_identifier) @ann (#match? @ann "RequestMapping$")) (value_arguments (value_argument (simple_identifier) @key (#match? @key "^(path|value)$") @@ -308,7 +522,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { (modifiers (annotation (constructor_invocation - (user_type (type_identifier) @ann (#eq? @ann "RequestMapping")) + (user_type (type_identifier) @ann (#match? @ann "RequestMapping$")) (value_arguments (value_argument . ${arrayOfArg('@prefix')}))))) (type_identifier) @cls) @class @@ -321,7 +535,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { (modifiers (annotation (constructor_invocation - (user_type (type_identifier) @ann (#eq? @ann "RequestMapping")) + (user_type (type_identifier) @ann (#match? @ann "RequestMapping$")) (value_arguments (value_argument (simple_identifier) @key (#match? @key "^(path|value)$") @@ -347,7 +561,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { (modifiers (annotation (constructor_invocation - (user_type (type_identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Mapping$")) + (user_type (type_identifier) @ann (#match? @ann "(Request|Get|Post|Put|Delete|Patch)Mapping$")) (value_arguments (value_argument . [(string_literal) @path (collection_literal (string_literal) @path)]))))) (simple_identifier) @method_name) @method @@ -360,7 +574,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { (modifiers (annotation (constructor_invocation - (user_type (type_identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Mapping$")) + (user_type (type_identifier) @ann (#match? @ann "(Request|Get|Post|Put|Delete|Patch)Mapping$")) (value_arguments (value_argument (simple_identifier) @key (#match? @key "^(path|value)$") @@ -375,7 +589,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { (modifiers (annotation (constructor_invocation - (user_type (type_identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Mapping$")) + (user_type (type_identifier) @ann (#match? @ann "(Request|Get|Post|Put|Delete|Patch)Mapping$")) (value_arguments (value_argument . ${arrayOfArg('@path')}))))) (simple_identifier) @method_name) @method @@ -388,7 +602,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { (modifiers (annotation (constructor_invocation - (user_type (type_identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Mapping$")) + (user_type (type_identifier) @ann (#match? @ann "(Request|Get|Post|Put|Delete|Patch)Mapping$")) (value_arguments (value_argument (simple_identifier) @key (#match? @key "^(path|value)$") @@ -399,6 +613,153 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { ], } satisfies LanguagePatterns>); + // ─── 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 (#match? @ann "RequestMapping$")) + (value_arguments (value_argument) @arg)))) + (type_identifier) @cls) @class + `, + }, + ], + } satisfies LanguagePatterns>); + + 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 "(Request|Get|Post|Put|Delete|Patch)Mapping$")) + (value_arguments (value_argument) @arg)))) + (simple_identifier) @method_name) @method + `, + }, + ], + } satisfies LanguagePatterns>); + + 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>); + + /** + * 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, + ): Set => { + const ids = new Set(); + for (const match of runCompiledPatterns(SPRING_CONST_CLASS_PREFIX_PATTERNS, tree)) { + const argNode = match.captures.arg; + const classNode = match.captures.class; + const annNode = match.captures.ann; + if (!argNode || !classNode) continue; + if (annNode && !isClassLevelMappingAnnotation(annNode.text)) 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 => { + const ids = new Set(); + 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 @@ -868,29 +1229,79 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { const kotlinFunctionName = (fn: Parser.SyntaxNode): string | null => fn.namedChildren.find((c) => c.type === 'simple_identifier')?.text ?? null; + const kotlinTypeRequestMethods = (typeNode: Parser.SyntaxNode): readonly string[] => { + const modifiers = typeNode.namedChildren.find((child) => child.type === 'modifiers'); + const mappings = (modifiers?.namedChildren ?? []).filter((annotation) => { + if (annotation.type !== 'annotation') return false; + return isClassLevelMappingAnnotation(kotlinAnnotationName(annotation) ?? ''); + }); + if (mappings.length === 0) return ['*']; + if (mappings.length !== 1) return []; + const mapping = mappings[0]; + const mappingName = kotlinAnnotationName(mapping); + if (!mappingName) return []; + return springAnnotationHttpMethods(mappingName, mapping.text); + }; + + const kotlinClassHttpMethodsById = (tree: Parser.Tree) => + new Map( + tree.rootNode + .descendantsOfType('class_declaration') + .map((typeNode) => [typeNode.id, kotlinTypeRequestMethods(typeNode)] as const), + ); + const collectKotlinSpringTypes = (filePath: string, tree: Parser.Tree): SharedSpringType[] => { // Class-level @RequestMapping prefixes (reuse the provider class-prefix query). const prefixByClassId = new Map(); for (const match of runCompiledPatterns(SPRING_CLASS_PREFIX_PATTERNS, tree)) { const prefixNode = match.captures.prefix; const classNode = match.captures.class; + const annNode = match.captures.ann; if (!prefixNode || !classNode) continue; + if (annNode && !isClassLevelMappingAnnotation(annNode.text)) 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>(); + const classHttpMethodsById = kotlinClassHttpMethodsById(tree); + 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; const methodNode = match.captures.method; if (!annNode || !pathNode || !methodNode) continue; - const httpMethod = METHOD_ANNOTATION_TO_HTTP[annNode.text]; - if (!httpMethod) continue; + const httpMethods = springAnnotationHttpMethods( + annNode.text, + enclosingAnnotationText(annNode), + ); + if (httpMethods.length === 0) 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 constrainedMethods = intersectSpringHttpMethods( + owner ? (classHttpMethodsById.get(owner.id) ?? ['*']) : ['*'], + httpMethods, + ); const arr = routesByMethodId.get(methodNode.id) ?? []; - arr.push({ method: httpMethod, path: rawPath }); + for (const httpMethod of constrainedMethods) { + arr.push({ method: httpMethod, path: rawPath }); + } routesByMethodId.set(methodNode.id, arr); } @@ -931,19 +1342,112 @@ 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(); + 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 || + (mc.wildcardImports?.length ?? 0) > 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 || + (mc.wildcardImports?.length ?? 0) > 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(); for (const match of runCompiledPatterns(SPRING_CLASS_PREFIX_PATTERNS, tree)) { const prefixNode = match.captures.prefix; const classNode = match.captures.class; + const annNode = match.captures.ann; if (!prefixNode || !classNode) continue; + if (annNode && !isClassLevelMappingAnnotation(annNode.text)) 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 +1460,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(); for (const match of runCompiledPatterns(SPRING_HTTP_EXCHANGE_CLASS_PATTERNS, tree)) { const classNode = match.captures.class; @@ -971,24 +1476,111 @@ 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; + }> = []; + const classHttpMethodsById = kotlinClassHttpMethodsById(tree); 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 httpMethods = springAnnotationHttpMethods( + annNode.text, + enclosingAnnotationText(annNode), + ); + if (httpMethods.length === 0) continue; const rawPath = unquoteLiteral(pathNode.text); if (rawPath === null) continue; + for (const httpMethod of httpMethods) { + 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 httpMethods = springAnnotationHttpMethods( + annNode.text, + enclosingAnnotationText(annNode), + ); + if (httpMethods.length === 0) 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; + for (const httpMethod of httpMethods) { + methodRoutes.push({ + httpMethod, + rawPath, + nameNode: match.captures.method_name, + methodNode, + }); + } + } + + const constrainedMethodRoutes = methodRoutes.flatMap((route) => { + const owner = findEnclosingClass(route.methodNode); + const classMethods = owner ? (classHttpMethodsById.get(owner.id) ?? ['*']) : ['*']; + return intersectSpringHttpMethods(classMethods, [route.httpMethod]).map((httpMethod) => ({ + ...route, + httpMethod, + })); + }); + + for (const { httpMethod, rawPath, nameNode, methodNode } of constrainedMethodRoutes) { 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 +1594,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 +1767,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', diff --git a/gitnexus/src/core/group/extractors/http-patterns/node.ts b/gitnexus/src/core/group/extractors/http-patterns/node.ts index 0d1298f72..9edfd69eb 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/node.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/node.ts @@ -9,11 +9,29 @@ import { type LanguagePatterns, type PatternSpec, } from '../tree-sitter-scanner.js'; -import type { HttpDetection, HttpLanguagePlugin } from './types.js'; +import type { HttpDetection, HttpLanguagePlugin, RepoContext } from './types.js'; +import { MAX_FOLD_LENGTH } from '../../../ingestion/route-extractors/constant-resolver.js'; +import { + DATA_ROUTE_TABLE_SOURCE, + propertyName, + 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, + isAxiosNamespace, + isHttpClientRef, + resolveJsPathExpression, + type JsModuleFacts, + type JsRepoFacts, +} from '../../../ingestion/route-extractors/js-const-resolver.js'; /** * 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 @@ -28,34 +46,8 @@ import type { HttpDetection, HttpLanguagePlugin } from './types.js'; * 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> = { - 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> = { - 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> = { @@ -94,15 +86,28 @@ const FETCH_WITH_OPTIONS_SPEC: PatternSpec> = { `, }; -// ─── Consumer: axios.get/post/... ──────────────────────────────────── -const AXIOS_SPEC: PatternSpec> = { +// ─── Consumer: .get/post/... ───────────────────────────── +// Widened from a literal `axios` receiver with a literal path. Application +// code satisfies neither: it calls through a configured instance +// (`const api = axios.create({ baseURL })`, imported at the call site under +// whatever name the app chose) and passes the path by reference from a shared +// route table (`api.get(API_ROUTE_PATH.LINKS)`). The query therefore matches +// ANY identifier receiver with an HTTP-verb method and ANY first argument; +// `scanBundle` admits a match only after PROVING the receiver is an axios +// instance and resolving the argument to a path. +// +// The proof gate is load-bearing, not belt-and-braces: EXPRESS_SPEC above +// matches `router.get('/x', handler)` / `app.post(...)` as PROVIDERS. A +// receiver admitted on spelling alone would re-emit every Express route in the +// repo as a consumer of itself, on both sides of every cross-repo pair. +const HTTP_CLIENT_SPEC: PatternSpec> = { meta: {}, query: ` (call_expression function: (member_expression - object: (identifier) @obj (#eq? @obj "axios") + object: (identifier) @obj property: (property_identifier) @http_method (#match? @http_method "^(get|post|put|delete|patch)$")) - arguments: (arguments . [(string) (template_string)] @path)) + arguments: (arguments . (_) @path)) `, }; @@ -148,16 +153,46 @@ const AXIOS_OBJECT_SPEC: PatternSpec> = { `, }; +// ─── Consumer: wrapped client X.request({ url, method }) ──────────── +// Enterprise wrapper shape: an axios instance (or a named request helper) +// re-exported under a local name — `httpClient.request({ url, method })` +// from `@winex-plugin/win-request`, `$http.request(...)`. Generic names +// like `api` need axios.create/import proof (`isHttpClientRef`); spelling +// alone is too common (graphql-request helpers, domain `api` objects). +// The member property is `request` (not an HTTP verb), so this cannot +// collide with the Express provider pattern (`router.get`) or the axios +// member form (`axios.get`). Option keys are resolved programmatically, +// same as the jQuery ajax / axios object forms. +// +// The query captures the receiver so scan can reject unrelated +// `.request({ url })` APIs (`cy.request`, `queue.request`). +const REQUEST_OBJECT_SPEC: PatternSpec> = { + meta: {}, + query: ` + (call_expression + function: (member_expression + object: (_) @obj + property: (property_identifier) @fn (#eq? @fn "request")) + arguments: (arguments . (object) @options)) + `, +}; + +/** + * Receivers admitted as wrapped HTTP clients without axios.create proof. + * Spelling-only: the last identifier in `obj.text` (`this.$http` → `$http`). + * Keep this set small — every extra name is a false-positive surface. + */ +const WRAPPED_REQUEST_RECEIVERS = new Set(['httpClient', '$http']); + interface NodePatternBundle { - controller: CompiledPatterns>; - methodDecorator: CompiledPatterns>; express: CompiledPatterns>; fetchNoOptions: CompiledPatterns>; fetchWithOptions: CompiledPatterns>; - axios: CompiledPatterns>; + httpClient: CompiledPatterns>; jqueryShorthand: CompiledPatterns>; jqueryAjax: CompiledPatterns>; axiosObject: CompiledPatterns>; + requestObject: CompiledPatterns>; } function compileBundle(language: unknown, name: string): NodePatternBundle { @@ -168,15 +203,14 @@ function compileBundle(language: unknown, name: string): NodePatternBundle { patterns: [spec], } satisfies LanguagePatterns>); 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'), - axios: mk(AXIOS_SPEC, 'axios'), + httpClient: mk(HTTP_CLIENT_SPEC, 'http-client'), jqueryShorthand: mk(JQUERY_SHORTHAND_SPEC, 'jquery-shorthand'), jqueryAjax: mk(JQUERY_AJAX_SPEC, 'jquery-ajax'), axiosObject: mk(AXIOS_OBJECT_SPEC, 'axios-object'), + requestObject: mk(REQUEST_OBJECT_SPEC, 'request-object'), }; } @@ -184,39 +218,13 @@ 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 = { - 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 * of `keyNames`. Returns null when no matching pair is present or the * value is not a string literal. Used by the jQuery ajax / axios object * consumers to resolve `url` / `method` / `type` keys in any order. + * Keys use shared `propertyName` so quoted `"method"` matches `method`. */ function readStringProp(objectNode: Parser.SyntaxNode, keyNames: readonly string[]): string | null { for (let i = 0; i < objectNode.namedChildCount; i++) { @@ -225,7 +233,8 @@ function readStringProp(objectNode: Parser.SyntaxNode, keyNames: readonly string const keyNode = pair.childForFieldName('key'); const valueNode = pair.childForFieldName('value'); if (!keyNode || !valueNode) continue; - if (!keyNames.includes(keyNode.text)) continue; + const key = propertyName(keyNode); + if (key === null || !keyNames.includes(key)) continue; if (valueNode.type !== 'string' && valueNode.type !== 'template_string') continue; const lit = unquoteLiteral(valueNode.text); if (lit !== null) return lit; @@ -234,65 +243,77 @@ function readStringProp(objectNode: Parser.SyntaxNode, keyNames: readonly string } /** - * 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. + * Verb for wrapped `X.request({ url, method|type })`. Absent key → GET + * (same default as fetch-without-options / jQuery ajax). Present but not a + * string/template, supplied only via object spread, or later overwritten by + * a duplicate key / spread → `*` so matching can still link without pinning GET. + * Later properties win, matching JavaScript object-literal evaluation. */ -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; +function readRequestMethod( + objectNode: Parser.SyntaxNode, + keyNames: readonly string[] = ['method', 'type'], +): string { + type Verb = { kind: 'absent' } | { kind: 'literal'; value: string } | { kind: 'unknown' }; + let last: Verb = { kind: 'absent' }; + for (let i = 0; i < objectNode.namedChildCount; i++) { + const child = objectNode.namedChild(i); + if (!child) continue; + if (child.type === 'spread_element') { + last = { kind: 'unknown' }; + continue; } + if ( + child.type === 'shorthand_property_identifier' || + child.type === 'shorthand_property_identifier_pattern' + ) { + if (keyNames.includes(child.text)) last = { kind: 'unknown' }; + continue; + } + if (child.type !== 'pair') continue; + const keyNode = child.childForFieldName('key'); + const valueNode = child.childForFieldName('value'); + if (!keyNode) continue; + const key = propertyName(keyNode); + if (key === null || !keyNames.includes(key)) continue; + if (!valueNode || (valueNode.type !== 'string' && valueNode.type !== 'template_string')) { + last = { kind: 'unknown' }; + continue; + } + const lit = unquoteLiteral(valueNode.text); + if (lit === null || lit.includes('${')) { + last = { kind: 'unknown' }; + continue; + } + last = { kind: 'literal', value: lit }; } - // Case 2: decorator is inside a class_body (decorating a method) — - // walk up to the enclosing class_declaration. - return findEnclosingClass(decoratorNode); + if (last.kind === 'literal') return last.value.toUpperCase(); + if (last.kind === 'unknown') return '*'; + return 'GET'; } -/** - * 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; - } +function wrappedRequestReceiverName(receiver: string): string { + const parts = receiver.split('.'); + return parts[parts.length - 1] ?? receiver; +} + +/** Axios module / axios.create instance, or a registered wrapper identifier. */ +function isAdmittedWrappedRequestReceiver( + receiver: string, + fileKey: string | undefined, + facts: JsRepoFacts | null, +): boolean { + if (WRAPPED_REQUEST_RECEIVERS.has(wrappedRequestReceiverName(receiver))) return true; + try { + const isModule = + facts === null || fileKey === undefined + ? receiver === 'axios' + : isAxiosNamespace(fileKey, receiver, facts); + if (isModule) return true; + if (!facts || fileKey === undefined) return false; + return isHttpClientRef(fileKey, receiver, facts); + } catch { + return false; } - return null; } /** @@ -305,12 +326,22 @@ function findDecoratedMethod(decoratorNode: Parser.SyntaxNode): Parser.SyntaxNod */ function buildImportMap(tree: Parser.Tree): Map { const map = new Map(); - const walk = (node: Parser.SyntaxNode): void => { + // Both walks are explicit-stack, not recursive. They visit EVERY node of the + // file, so their depth is the source's nesting depth — and `scan` may not + // throw: a `RangeError` here escapes to `sync.ts`, which records the repo as + // an unexplained "missing repo" and drops every contract of every kind for + // it, silently. A file nesting template substitutions ~4 000 deep (well + // inside what tree-sitter will parse) was enough. + const stack: Parser.SyntaxNode[] = [tree.rootNode]; + while (stack.length > 0) { + const node = stack.pop() as Parser.SyntaxNode; if (node.type === 'import_statement') { const sourceNode = node.childForFieldName('source'); const module = sourceNode ? unquoteLiteral(sourceNode.text) : null; if (module !== null) { - const collect = (n: Parser.SyntaxNode): void => { + const inner: Parser.SyntaxNode[] = [node]; + while (inner.length > 0) { + const n = inner.pop() as Parser.SyntaxNode; if (n.type === 'import_specifier') { const nameNode = n.childForFieldName('name'); const aliasNode = n.childForFieldName('alias'); @@ -319,81 +350,267 @@ function buildImportMap(tree: Parser.Tree): Map(); + +/** + * Skip ceiling for the pre-pass, mirroring the analyzer's default + * `--max-file-size`. A minified bundle is megabytes on one line and defines no + * route table a human wrote; parsing it costs far more than it can return. + */ +const MAX_PREPASS_FILE_BYTES = 512 * 1024; + +/** Repo-relative path in the same POSIX form the fact map is keyed by. */ +function normalizeRel(rel: string): string { + return rel.replace(/\\/g, '/').replace(/^\.\//, ''); +} + +/** The grammar a JS/TS-family file should be parsed with, or null if not one. */ +function grammarForFile(rel: string): unknown | null { + const lower = rel.toLowerCase(); + if (lower.endsWith('.tsx')) return TypeScript.tsx; + if (/\.[cm]?ts$/.test(lower)) return TypeScript.typescript; + if (/\.[cm]?jsx?$/.test(lower)) return JavaScript; + return null; +} + +function buildNodeRepoContext(args: { + files: string[]; + readFile: (rel: string) => string | null; + parseSource: (parser: Parser, src: string) => Parser.Tree | null; +}): NodeRepoContext { + const cached = REPO_CONTEXT_BY_FILE_LIST.get(args.files); + if (cached) return cached; + + const byFile = new Map(); + const parsers = new Map(); + const parserFor = (language: unknown): Parser => { + let parser = parsers.get(language); + if (!parser) { + parser = new Parser(); + parser.setLanguage(language as Parameters[0]); + parsers.set(language, parser); + } + return parser; + }; + + // Cost gate, in the spirit of the sibling `python.ts` pre-pass: every fact + // this map holds exists to prove a receiver is an axios instance or to fold a + // path for one. A repo where the string `axios` appears nowhere can prove no + // receiver, so every parse below is dead work — and parsing is the expensive + // half (measured 4.36 s / +258 MB RSS over 827 TypeScript files, on top of + // the parse `getScanInput` already does). + // Only the file's identity is carried between the passes, never its text: a + // large monorepo's whole source tree held in one array at once is the shape + // that produced the analyzer's scale problems, and the second read is cheap + // beside the parse it gates. + const eligible: Array<{ rel: string; language: unknown }> = []; + let sawAxios = false; + for (const rel of args.files) { + const language = grammarForFile(rel); + if (language === null) continue; + const content = args.readFile(rel); + // `MAX_PREPASS_FILE_BYTES` is a BYTE ceiling; `String.length` counts UTF-16 + // code units, which under-counts every multi-byte source. + if (content === null || Buffer.byteLength(content, 'utf8') > MAX_PREPASS_FILE_BYTES) continue; + if (!sawAxios && content.includes('axios')) sawAxios = true; + eligible.push({ rel, language }); + } + + if (sawAxios) { + for (const { rel, language } of eligible) { + try { + const content = args.readFile(rel); + if (content === null) continue; + // `parseSource` belongs INSIDE the guard: `safe-parse.ts` throws + // `ParseTimeoutError` and makes catching it a per-caller obligation, and + // `prepareRepo` is contractually non-throwing. One escape here left the + // fact map unwritten for the WHOLE repo — and, because the orchestrator + // caches per plugin NAME, made all three JS/TS plugins re-walk it and + // fail the same way before falling back to literal-only scanning. + const tree = args.parseSource(parserFor(language), content); + if (!tree) continue; + byFile.set(normalizeRel(rel), extractJsModuleFacts(tree)); + } catch { + // One malformed file must never abort the pre-pass — it simply stays + // unresolved, exactly as it is without this pass at all. + } + } + } + + const ctx: NodeRepoContext = { facts: buildJsRepoFacts(byFile) }; + REPO_CONTEXT_BY_FILE_LIST.set(args.files, ctx); + return ctx; +} + +/** The repo facts to resolve against, or null when there was no pre-pass. */ +function resolveFactsFor( + repoContext: RepoContext | undefined, + fileRel: string | undefined, +): JsRepoFacts | null { + const ctx = repoContext as NodeRepoContext | undefined; + if (!ctx || fileRel === undefined) return null; + return ctx.facts; +} + +/** + * Whether a folded first argument is plausibly a URL path. + * + * The query now captures ANY first argument, and "it folded to a string" is not + * "it is a path" — `normalizeConsumerPath` is a canonicalizer, not a validator, + * and it happily turns non-paths into contracts that exact-match real provider + * routes: + * + * api.get(CONFIG.TIMEOUT) // "5000" -> http::GET::/{param} + * api.post(MSG.ERROR) // "Could not reach the …" -> http::POST::/could not reach the server + * + * `/{param}` matches every one-segment provider route in the group, and + * `matching.exclude_links_param_only_paths` defaults to `false`. A path whose + * leading term is an unresolved placeholder is refused unless the next + * character is `/` — that is the gateway-prefix shape + * `` `${serviceClient}/api/v1/x` `` that `stripLeadingTemplatePrefix` keeps. + * Bare `{param}` and `{param}api/x` stay rejected: nothing pins a route. + */ +function looksLikeHttpPath(path: string): boolean { + if (path === '') return false; + if (/^https?:\/\//i.test(path)) return true; + // A `${…}` term is a runtime value that `normalizeConsumerPath` rewrites to + // `{param}`; its SOURCE text can be any expression (`${draft ? 'a' : 'b'}`, + // `${id ?? ''}`), so the checks below have to run against the normalized + // shape. Testing the raw source dropped every partially folded path whose + // unresolved term happened to contain a space. + const shape = path.replace(/\$\{[^}]+\}/g, '{param}'); + if (/\s/.test(shape)) return false; + if (shape.startsWith('{param}') && !shape.startsWith('{param}/')) return false; + // An all-digit string is a path only when it is written as one. A leading + // slash is that evidence: `client.get('/123')` is a route whose segment the + // consumer normalizer reads as `{param}`, while a bare `"5000"` folded out of + // `CONFIG.TIMEOUT` is a timeout that would match every one-segment provider. + if (!shape.startsWith('/')) return !/^\d+$/.test(shape); + return true; +} + +/** + * The path a consumer call's first argument denotes. + * + * Prefers full resolution against the repo facts; falls back to the raw + * literal for a string/template node so a repo with no pre-pass (or an + * unresolvable reference) behaves exactly as it did before. + * + * `fileKey` is already `normalizeRel`-ed by the caller — see `scanBundle`. + * + * `legacyShape` marks the exact combination this pattern matched BEFORE it was + * widened: the literal receiver `axios` with a string or template-string first + * argument. That combination keeps its old output verbatim, so this PR adds + * detections without removing any — `axios.get(`${API_BASE}/users`)` still + * yields `/{param}/users`. Everything the widened query NEWLY admits (any other + * receiver, or any non-literal argument) has to clear the gates. + */ +function resolveConsumerPath( + pathNode: Parser.SyntaxNode, + facts: JsRepoFacts | null, + fileKey: string | undefined, + legacyShape: boolean, +): string | null { + if (facts && fileKey !== undefined) { + const resolved = resolveJsPathExpression(fileKey, pathNode, facts); + if (resolved !== null && looksLikeHttpPath(resolved)) return resolved; + } + // The fallback is deliberately gated on node TYPE: `unquoteLiteral` returns + // unrecognized input unchanged, so handing it a `member_expression` would + // yield the literal text `API_ROUTE_PATH.LINKS` as if it were a URL path. + if (pathNode.type !== 'string' && pathNode.type !== 'template_string') return null; + const literal = unquoteLiteral(pathNode.text); + // The fold bails past `MAX_FOLD_LENGTH`; the raw source it falls back to has + // no such bound and lands in `contractId` and `meta.path` all the same. + if (literal === null || literal.length > MAX_FOLD_LENGTH) return null; + return legacyShape || looksLikeHttpPath(literal) ? literal : null; +} + +function scanBundle( + bundle: NodePatternBundle, + tree: Parser.Tree, + repoContext?: RepoContext, + fileRel?: string, +): HttpDetection[] { const out: HttpDetection[] = []; + // Repo-wide constant / HTTP-client facts, when the orchestrator ran the + // `prepareRepo` pre-pass. Absent for a bare `scan(tree)` call, in which case + // every cross-file resolution below floors to the literal-only behavior. + const facts = resolveFactsFor(repoContext, fileRel); + // The fact map is keyed by `normalizeRel(rel)`. Normalizing at ONE place and + // using that value for every read keeps the two sides in step: the receiver + // gate used to read the raw `fileRel`, and `isHttpClientRef` cannot tell a key + // miss from "not a client", so any non-POSIX path (glob v13 has no + // `posix: true` and its walker joins with the platform separator; graph rows + // are a second unnormalized source) silently returned zero consumers. + const fileKey = fileRel === undefined ? undefined : normalizeRel(fileRel); // Local-binding → { declared export name, module } for the file's named // imports, so an express handler that is an imported (possibly aliased) // 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(); - 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, }); } @@ -467,22 +684,57 @@ function scanBundle(bundle: NodePatternBundle, tree: Parser.Tree): HttpDetection }); } - // Consumer: axios.(url) - for (const match of runCompiledPatterns(bundle.axios, tree)) { + // Consumer: .(url) — `axios` itself, or any receiver the + // repo pre-pass proves is an axios instance. + for (const match of runCompiledPatterns(bundle.httpClient, tree)) { const methodNode = match.captures.http_method; const pathNode = match.captures.path; - if (!methodNode || !pathNode) continue; - const path = unquoteLiteral(pathNode.text); - if (path === null) continue; - out.push({ - role: 'consumer', - framework: 'axios', - method: methodNode.text.toUpperCase(), - path, - name: null, - line: pathNode.startPosition.row + 1, - confidence: 0.7, - }); + const objNode = match.captures.obj; + if (!methodNode || !pathNode || !objNode) continue; + + // Receiver gate. `axios.get(...)` needs no proof; anything else must be + // traced to an `axios.create(...)` binding, or it is not ours to claim. + const receiver = objNode.text; + + // Cross-file resolution is the only work in this file that walks a + // repo-wide graph, and `HttpLanguagePlugin.scan` may not throw: a single + // hostile call site must cost its own detection, not the repo's whole + // contract set (`sync.ts` catches a throw here as an unexplained "missing + // repo", silently, for every contract type). + try { + // The receiver is admitted when it IS the axios module — the bare + // spelling this pattern trusted before it was widened, or a declared + // import/require of 'axios' under any name — or when it traces to an + // `axios.create(...)` instance. Nothing else. + const isModule = + facts === null || fileKey === undefined + ? receiver === 'axios' + : isAxiosNamespace(fileKey, receiver, facts); + if (!isModule) { + if (!facts || fileKey === undefined) continue; + if (!isHttpClientRef(fileKey, receiver, facts)) continue; + } + + const path = resolveConsumerPath( + pathNode, + facts, + fileKey, + isModule && (pathNode.type === 'string' || pathNode.type === 'template_string'), + ); + if (path === null) continue; + + out.push({ + role: 'consumer', + framework: 'axios', + method: methodNode.text.toUpperCase(), + path, + name: null, + line: pathNode.startPosition.row + 1, + confidence: 0.7, + }); + } catch { + // Unresolvable is the same outcome as unresolved — skip this call site. + } } // Consumer: jQuery shorthand $.get(url) / $.post(url, ...) @@ -531,8 +783,7 @@ function scanBundle(bundle: NodePatternBundle, tree: Parser.Tree): HttpDetection if (!optionsNode) continue; const path = readStringProp(optionsNode, ['url']); if (path === null) continue; - const rawMethod = readStringProp(optionsNode, ['method']); - const method = (rawMethod ?? 'GET').toUpperCase(); + const method = readRequestMethod(optionsNode, ['method']); out.push({ role: 'consumer', framework: 'axios', @@ -544,23 +795,77 @@ function scanBundle(bundle: NodePatternBundle, tree: Parser.Tree): HttpDetection }); } + // Consumer: wrapped client `X.request({ url, method })` — the shared + // enterprise axios-instance shape (`httpClient.request` from + // win-request and friends). Emit the raw url (templates intact) so + // shared `normalizeConsumerPath` can strip a leading `${…}` gateway + // prefix and fold mid/tail interpolations to `{param}`. A plugin-side + // longest-slash-segment reducer would truncate those mid-templates + // before the shared normalizer ever saw them. This scan drops only + // static relative urls (no `${`, no leading `/`, not `https?://`). + // That filter is request-wrapper-specific: fetch/axios member forms + // already admit absolute urls and leave host stripping to + // `normalizeConsumerPath`. + for (const match of runCompiledPatterns(bundle.requestObject, tree)) { + const optionsNode = match.captures.options; + const objNode = match.captures.obj; + if (!optionsNode || !objNode) continue; + if (!isAdmittedWrappedRequestReceiver(objNode.text, fileKey, facts)) continue; + const rawUrl = readStringProp(optionsNode, ['url']); + if (rawUrl === null) continue; + const url = rawUrl.trim(); + if (!url.includes('${') && !url.startsWith('/') && !/^https?:\/\//i.test(url)) continue; + out.push({ + role: 'consumer', + framework: 'request', + method: readRequestMethod(optionsNode), + path: url, + name: null, + line: optionsNode.startPosition.row + 1, + confidence: 0.65, + }); + } + + for (const route of scanDataRouteTables(tree)) { + const imported = + route.handlerLocalName === undefined ? undefined : importMap.get(route.handlerLocalName); + out.push({ + role: 'provider', + framework: DATA_ROUTE_TABLE_SOURCE, + method: route.method, + path: route.path, + // A source-only scan can prove a bare local/imported binding. Member + // ownership needs the semantic model, so leave it unattributed here; + // the graph-backed path consumes the exact handlerSymbolId later. + name: imported?.name ?? (route.handlerLocalName === undefined ? null : route.handlerName), + ...(imported === undefined ? {} : { handlerImport: imported }), + strictHandlerResolution: true, + ...(route.handlerLocalName === undefined ? { unresolvedHandler: true } : {}), + line: route.line, + confidence: 0.8, + }); + } + return out; } export const JAVASCRIPT_HTTP_PLUGIN: HttpLanguagePlugin = { name: 'javascript-http', language: JavaScript, - scan: (tree) => scanBundle(JAVASCRIPT_BUNDLE, tree), + prepareRepo: buildNodeRepoContext, + scan: (tree, repoContext, fileRel) => scanBundle(JAVASCRIPT_BUNDLE, tree, repoContext, fileRel), }; export const TYPESCRIPT_HTTP_PLUGIN: HttpLanguagePlugin = { name: 'typescript-http', language: TypeScript.typescript, - scan: (tree) => scanBundle(TYPESCRIPT_BUNDLE, tree), + prepareRepo: buildNodeRepoContext, + scan: (tree, repoContext, fileRel) => scanBundle(TYPESCRIPT_BUNDLE, tree, repoContext, fileRel), }; export const TSX_HTTP_PLUGIN: HttpLanguagePlugin = { name: 'tsx-http', language: TypeScript.tsx, - scan: (tree) => scanBundle(TSX_BUNDLE, tree), + prepareRepo: buildNodeRepoContext, + scan: (tree, repoContext, fileRel) => scanBundle(TSX_BUNDLE, tree, repoContext, fileRel), }; diff --git a/gitnexus/src/core/group/extractors/http-patterns/php.ts b/gitnexus/src/core/group/extractors/http-patterns/php.ts index bf2eb7aba..65a1896c9 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/php.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/php.ts @@ -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> = { @@ -71,11 +87,31 @@ const FILE_GET_CONTENTS_SPEC: PatternSpec> = { `, }; +/** + * `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> = { + meta: {}, + query: ` + (object_creation_expression + [(name) (qualified_name)] @class + (arguments + . (argument (_) @methodArg) + . (argument (_) @pathArg))) + `, +}; + interface PhpPatternBundle { laravelRoute: CompiledPatterns>; httpFacade: CompiledPatterns>; guzzleMember: CompiledPatterns>; fileGetContents: CompiledPatterns>; + guzzleRequestCtor: CompiledPatterns>; } const mk = (spec: PatternSpec>, 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 ` . `, 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 = '';` 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::`, 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; }, }; diff --git a/gitnexus/src/core/group/extractors/http-patterns/python.ts b/gitnexus/src/core/group/extractors/http-patterns/python.ts index 7d8bd99af..7a0fa2f7f 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/python.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/python.ts @@ -1137,13 +1137,17 @@ export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = { name: 'python-http', language: Python, // routeCoverage intentionally LEFT at the default 'partial' (#2138 Part 2). - // It would be a no-op even if set to 'complete': FastAPI decorator routes set - // no handlerName (generic worker path) and Django sets methodName: null, so no - // Python file ever resolves a handlerSymbolId and none would be parse-skipped. - // Declaring 'complete' now is only a latent trap for the moment a follow-up - // gives FastAPI routes a handlerName. `hasConsumerSignals` is kept (and is a - // true superset of scan()'s consumer shapes) so the precondition already holds - // when Python is later flipped to 'complete'. + // 'complete' is now an active data-loss risk rather than a no-op: FastAPI and + // Flask decorator routes do carry a handlerName (Python's + // `decoratorRouteHandlerName` hook reads the `decorated_definition`), so their + // files can resolve every handlerSymbolId and become parse-skip candidates. + // The flag asserts more than that — it asserts ingestion emits a Route node + // for EVERY provider route this scan() finds, and it does not: Flask's + // imperative `add_url_rule('/p', view_func=handler)` registration below has no + // ingestion counterpart, so skipping a file that mixes it with resolved + // decorator routes would drop those providers. `hasConsumerSignals` is kept + // (and is a true superset of scan()'s consumer shapes) so the consumer half of + // the precondition already holds once provider parity is closed. // Consumer signals scan() can detect: `requests.`/`requests.request`, // `httpx` (sync/async client), the `uri=`/`url=` keyword/variable wrapper // calls, plus aiohttp/urllib. Conservative — over-matching only costs a parse. diff --git a/gitnexus/src/core/group/extractors/http-patterns/types.ts b/gitnexus/src/core/group/extractors/http-patterns/types.ts index ffe59cc59..38c197704 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/types.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/types.ts @@ -56,6 +56,14 @@ export interface HttpDetection { * locally-defined or anonymous handlers. */ handlerImport?: { name: string; module: string }; + /** Resolve only from the registration file or exact import target; never guess repo-wide. */ + strictHandlerResolution?: boolean; + /** + * The plugin saw a provider handler designator but could not prove its owner. + * Prevents the orchestrator from treating it as an anonymous inline handler + * and attributing it to the containing registrar function. + */ + unresolvedHandler?: boolean; /** Confidence in (0, 1]. Source-scan plugins typically use 0.7–0.8. */ confidence: number; } diff --git a/gitnexus/src/core/group/extractors/http-route-extractor.ts b/gitnexus/src/core/group/extractors/http-route-extractor.ts index a6e0b046f..d116bc046 100644 --- a/gitnexus/src/core/group/extractors/http-route-extractor.ts +++ b/gitnexus/src/core/group/extractors/http-route-extractor.ts @@ -6,7 +6,9 @@ import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js import type { ExtractedContract, RepoHandle } from '../types.js'; import { readSafe } from './fs-utils.js'; import { parseSourceSafe } from '../../tree-sitter/safe-parse.js'; +import { toZeroBasedLine } from '../../ingestion/utils/line-base.js'; import { logger } from '../../logger.js'; +import { DATA_ROUTE_TABLE_SOURCE } from '../../ingestion/route-extractors/data-route-table.js'; import { getPluginForFile, HTTP_SCAN_GLOB, @@ -114,7 +116,7 @@ LIMIT 2`; // Resolve an IMPORTED handler by pinning it to the import's target module: the // declared export `$name` whose file is the module the handler was imported from -// (`$fileDot` matches `mod.ext`, `$fileSlash` matches `mod/index.ext`). This is +// (`$filePaths` contains exact source-file and directory-index candidates). This is // the precise rung — it survives aliases and local same-name collisions that a // repo-wide name lookup cannot, and only resolves on a unique match within that // module. `LIMIT 2` keeps the uniqueness count exact (see RESOLVE_BY_NAME_QUERY). @@ -128,13 +130,22 @@ MATCH (n) WHERE labels(n) IN ['Function','Method','CodeElement'] RETURN n.id AS uid, n.name AS name, n.filePath AS filePath LIMIT 2`; +// determinism: probe — uniqueness discriminator, not a window. The consumer +// accepts exactly one row and rejects a 2-row result whole, so row identity +// cannot affect the resolution decision. +export const RESOLVE_IN_EXACT_MODULE_QUERY = ` +MATCH (n) WHERE labels(n) IN ['Function','Method','CodeElement'] + AND n.name = $name AND n.filePath IN $filePaths +RETURN n.id AS uid, n.name AS name, n.filePath AS filePath +LIMIT 2`; + // Source-file extensions an import specifier may resolve to (stripped before // building the module file-prefix so `./h/users` and `./h/users.ts` agree). const SOURCE_EXT_RE = /\.(?:m|c)?[jt]sx?$/; /** * Resolve an import specifier to a repo-relative FILE BASE (path without - * extension) so the target module can be matched by `filePath STARTS WITH`. + * extension) so exact target-file candidates can be constructed. * Handles two relative-import dialects and returns null for bare/absolute * imports (which fall back to a repo-wide name lookup): * - path-style (JS/TS): `./handlers/users`, `../x` → joined against the @@ -160,6 +171,27 @@ function resolveModuleBase(fromFile: string, module: string): string | null { return null; // bare / absolute import — repo-wide fallback } +const MODULE_SOURCE_EXTENSIONS = [ + '.ts', + '.tsx', + '.mts', + '.cts', + '.js', + '.jsx', + '.mjs', + '.cjs', + '.py', +]; + +function moduleFileCandidates(base: string): string[] { + return [ + ...MODULE_SOURCE_EXTENSIONS.map((extension) => `${base}${extension}`), + ...MODULE_SOURCE_EXTENSIONS.map((extension) => + extension === '.py' ? `${base}/__init__.py` : `${base}/index${extension}`, + ), + ]; +} + interface ResolvedSymbol { uid: string; name: string; @@ -179,13 +211,17 @@ function resolveContainingSymbol( line: number, ): ResolvedSymbol | null { const norm = (x: unknown): string => String(x ?? ''); - // Detection lines are 1-based; symbol spans are stored 0-based for the - // languages indexed today (parse-worker records `startPosition.row`). So the - // base-correct probe is `line - 1`. Pick the INNERMOST (smallest-span) symbol - // whose span contains the probe. Only if nothing contains `line - 1` do we - // retry with the raw `line` — a defensive fallback for any future language - // that stores 1-based spans. Probing `line - 1` first (rather than OR-ing both) - // avoids the +1 slack mis-picking a one-line sibling that sits on `line`. + // Detection lines are 1-based (`HttpDetection.line`); symbol spans are stored + // 0-based for the languages indexed today (parse-worker records + // `startPosition.row`). So the base-correct probe is `toZeroBasedLine(line)` — + // the same named 1-based→graph-space conversion the ingestion emitters use + // (#2377), rather than a bare literal. Pick the INNERMOST (smallest-span) + // symbol whose span contains the probe. Only if nothing contains the 0-based + // probe do we retry with the raw `line` — a defensive fallback for any future + // language that stores 1-based spans. Probing 0-based first (rather than + // OR-ing both) avoids the +1 slack mis-picking a one-line sibling that sits on + // `line`. The helper's `Math.max(0, …)` clamp is inert here: every plugin sets + // `line` from `startPosition.row + 1`, so it is always >= 1. const pick = (probe: number): ResolvedSymbol | null => { let best: ResolvedSymbol | null = null; let bestSpan = Number.POSITIVE_INFINITY; @@ -208,7 +244,7 @@ function resolveContainingSymbol( } return best && best.uid ? best : null; }; - return pick(line - 1) ?? pick(line); + return pick(toZeroBasedLine(line)) ?? pick(line); } /** A Function/Method in the file matching `name` exactly (for named handlers). */ @@ -224,6 +260,17 @@ function resolveSymbolByName(rows: Record[], name: string): Res return null; } +function resolveFileSymbolByNameUnique( + rows: Record[], + name: string, +): ResolvedSymbol | null { + const matches = rows + .map((row) => resolveSymbolByName([row], name)) + .filter((match): match is ResolvedSymbol => match !== null); + const byUid = new Map(matches.map((match) => [match.uid, match])); + return byUid.size === 1 ? (byUid.values().next().value ?? null) : null; +} + // ─── Path normalization (shared between provider / consumer paths) ── /** @@ -245,13 +292,57 @@ export function normalizeHttpPath(p: string): string { } /** - * Consumer-side normalization is more aggressive: - * - template literals (`${x}`) → `{param}` - * - strip protocol + host if the URL is absolute - * - numeric segments → `{param}` (so `/api/orders/42` → `/api/orders/{param}`) + * Strip LEADING template interpolations from a consumer url as gateway/host + * bindings — the enterprise wrapper shape `` `${serviceClient}/api/v1/x` `` + * where `${serviceClient}` selects the gateway service, not a route segment. + * This is consumer-path framework semantics (mirrors how an absolute + * `https://host/path` url keeps only its path), so it lives here rather than + * in any one language plugin: + * - `` `${c}/api/x` `` → `/api/x` (clean prefix; `${c}${d}/api/x` → `/api/x`) + * - `` `${c}/api/x/${id}` ``→ `/api/x/${id}` (mid/tail interpolations are left for the `{param}` pass) + * Returns null when the stripped remainder is not a single-slash path: + * - remainder without `/` — relative fragment (`${c}api/x`) or scheme/host + * (`${scheme}://${host}/api/x`); whether it is a path depends on + * unverifiable runtime state, so it is dropped rather than guessed at; + * - remainder starting with `//` — protocol-relative (`${proto}//host/api/x`); + * keeping it would later collapse to `/host/api/x`. + * + * The `?` in a query string cannot leak into the brace matching: `${...}` + * spans are matched by braces here (before any `{param}` replacement), and + * `normalizeHttpPath` splits on `?` only after the whole `${...}` span — + * including any `?` inside it — has been collapsed to `{param}`. So + * `` `${c}/api/x?id=${id}` `` reduces to `/api/x` on both orderings. */ -function normalizeConsumerPath(url: string): string { - const templated = url.replace(/\$\{[^}]+\}/g, '{param}').trim(); +function stripLeadingTemplatePrefix(url: string): string | null { + if (!url.startsWith('${')) return url; + const rest = url.replace(/^(?:\$\{[^}]*\})+/, ''); + return rest.startsWith('/') && !rest.startsWith('//') ? rest : null; +} + +/** + * Placeholder substituted for `${...}` before WHATWG `URL` parsing so the + * parser cannot percent-encode our own `{param}` markers. A genuine encoded + * segment like `%7Bfoo%7D` then survives as a literal, instead of being + * rewritten into braces and folded into `{param}`. + * + * Private-use U+E000 cannot appear in a real URL path, so a literal + * `__gitnexus_http_param__` segment is not rewritten into `{param}`. + */ +const CONSUMER_PARAM_SENTINEL = '\uE000'; +const CONSUMER_PARAM_SENTINEL_ENC = '%ee%80%80'; + +function restoreConsumerParamSentinel(pathOnly: string): string { + return pathOnly + .split(CONSUMER_PARAM_SENTINEL) + .join('{param}') + .replace(new RegExp(CONSUMER_PARAM_SENTINEL_ENC, 'gi'), '{param}'); +} + +/** Canonicalize a consumer URL after `stripLeadingTemplatePrefix`. */ +function normalizeConsumerPath(url: string): string | null { + const stripped = stripLeadingTemplatePrefix(url.trim()); + if (stripped === null) return null; + const templated = stripped.replace(/\$\{[^}]+\}/g, CONSUMER_PARAM_SENTINEL).trim(); let pathOnly = templated; if (/^https?:\/\//i.test(templated)) { try { @@ -260,6 +351,7 @@ function normalizeConsumerPath(url: string): string { pathOnly = templated.replace(/^https?:\/\/[^/]+/i, ''); } } + pathOnly = restoreConsumerParamSentinel(pathOnly); const normalized = normalizeHttpPath(pathOnly || '/'); const segments = normalized .split('/') @@ -479,28 +571,29 @@ export class HttpRouteExtractor implements ContractExtractor { globalNameCache.set(name, result); return result; }; - // Resolve a handler imported from a RELATIVE module to the unique declared - // symbol of that name inside the import's target file. Returns null for - // non-relative (bare/aliased-path) imports — those fall back to the repo-wide - // name lookup. Cached by (target-file-prefix, declared name). + // Resolve a handler imported from a relative module to the unique declared + // symbol inside its target file. The caller decides whether a miss may use + // the historical unique repository-wide fallback. const importedSymbolCache = new Map(); const resolveImportedSymbol = async ( fromFile: string, imp: { name: string; module: string }, + strict = false, ): Promise => { if (!dbExecutor) return null; const base = resolveModuleBase(fromFile, imp.module); - if (base === null) return null; // bare/absolute import → repo-wide fallback - const cacheKey = JSON.stringify([base, imp.name]); + if (base === null) return null; + const cacheKey = JSON.stringify([base, imp.name, strict]); const cached = importedSymbolCache.get(cacheKey); if (cached !== undefined) return cached; let rows: Record[] = []; try { - rows = await dbExecutor(RESOLVE_IN_MODULE_QUERY, { - name: imp.name, - fileDot: `${base}.`, - fileSlash: `${base}/`, - }); + rows = await dbExecutor( + strict ? RESOLVE_IN_EXACT_MODULE_QUERY : RESOLVE_IN_MODULE_QUERY, + strict + ? { name: imp.name, filePaths: moduleFileCandidates(base) } + : { name: imp.name, fileDot: `${base}.`, fileSlash: `${base}/` }, + ); } catch { rows = []; } @@ -513,6 +606,7 @@ export class HttpRouteExtractor implements ContractExtractor { d: HttpDetection, ): Promise => { if (!dbExecutor) return null; + if (d.role === 'provider' && d.unresolvedHandler) return null; const syms = await loadFileSymbols(filePath); // Name resolution does NOT need a detection line — a named provider // handler (Spring/Go/etc. method name) resolves by name even when the @@ -526,14 +620,20 @@ export class HttpRouteExtractor implements ContractExtractor { // its (declared) name would be wrong; on a miss go straight to a unique // repo-wide match on the declared name, never file-scoped. if (d.handlerImport) { - const byImport = await resolveImportedSymbol(filePath, d.handlerImport); + const byImport = await resolveImportedSymbol( + filePath, + d.handlerImport, + d.strictHandlerResolution, + ); if (byImport) return byImport; - const byGlobal = await resolveSymbolByNameUnique(d.handlerImport.name); - if (byGlobal) return byGlobal; - return null; + if (d.strictHandlerResolution) return null; + return resolveSymbolByNameUnique(d.handlerImport.name); } - const byName = resolveSymbolByName(syms, d.name); + const byName = d.strictHandlerResolution + ? resolveFileSymbolByNameUnique(syms, d.name) + : resolveSymbolByName(syms, d.name); if (byName) return byName; + if (d.strictHandlerResolution) return null; const byGlobal = await resolveSymbolByNameUnique(d.name); if (byGlobal) return byGlobal; // A NAMED handler we could not resolve by name (neither file-scoped nor @@ -566,6 +666,7 @@ export class HttpRouteExtractor implements ContractExtractor { dbExecutor, getDetections, resolveDetectionSymbol, + loadFileSymbols, coveredFiles, ) : []; @@ -635,6 +736,7 @@ export class HttpRouteExtractor implements ContractExtractor { db: CypherExecutor, getDetections: (rel: string) => Promise, resolveSymbol: (filePath: string, d: HttpDetection) => Promise, + loadFileSymbols: (filePath: string) => Promise[]>, coveredFiles?: Set, ): Promise { const out: ExtractedContract[] = []; @@ -694,15 +796,11 @@ export class HttpRouteExtractor implements ContractExtractor { if (!method) method = 'GET'; symbolUid = handlerSymbolId; if (filePath) { - try { - const syms = await db(CONTAINING_QUERY, { filePath }); - const hit = syms.find((s) => String(s.uid ?? s[0]) === handlerSymbolId); - if (hit) { - symbolName = String(hit.name ?? hit[1]) || symbolName; - symPath = String(hit.filePath ?? hit[2]) || filePath; - } - } catch { - /* keep the authoritative uid + basename fallback */ + const syms = await loadFileSymbols(filePath); + const hit = syms.find((s) => String(s.uid ?? s[0]) === handlerSymbolId); + if (hit) { + symbolName = String(hit.name ?? hit[1]) || symbolName; + symPath = String(hit.filePath ?? hit[2]) || filePath; } } } else { @@ -784,7 +882,12 @@ export class HttpRouteExtractor implements ContractExtractor { getDetections: (rel: string) => Promise, resolveSymbol: (filePath: string, d: HttpDetection) => Promise, ): Promise { - const out: ExtractedContract[] = []; + const candidates: Array<{ + detection: HttpDetection; + filePath: string; + pathNorm: string; + resolved: ResolvedSymbol | null; + }> = []; for (const rel of files) { const detections = await getDetections(rel); const filePath = normalizeRepoRelPath(rel); @@ -795,27 +898,57 @@ export class HttpRouteExtractor implements ContractExtractor { // arrow that encloses the registration line) so the contract carries a // real symbolUid; fall back to the file + detection name otherwise. const resolved = await resolveSymbol(filePath, d); - out.push({ - contractId: contractIdFor(d.method, pathNorm), - type: 'http', - role: 'provider', - symbolUid: resolved?.uid ?? '', - symbolRef: { - filePath: resolved?.filePath || filePath, - name: resolved?.name ?? d.name ?? 'handler', - }, - symbolName: resolved?.name ?? d.name ?? 'handler', - confidence: d.confidence, - meta: { - method: d.method, - path: pathNorm, - pathSegments: pathNorm.split('/').filter(Boolean), - extractionStrategy: resolved ? 'source_scan_resolved' : 'source_scan', - framework: d.framework, - }, - }); + candidates.push({ detection: d, filePath, pathNorm, resolved }); } } + + const dataCandidatesByIdentity = new Map(); + for (const candidate of candidates) { + if (candidate.detection.framework !== DATA_ROUTE_TABLE_SOURCE) continue; + const identity = contractIdFor(candidate.detection.method, candidate.pathNorm); + const grouped = dataCandidatesByIdentity.get(identity) ?? []; + grouped.push(candidate); + dataCandidatesByIdentity.set(identity, grouped); + } + const ambiguousDataIdentities = new Set(); + for (const [identity, grouped] of dataCandidatesByIdentity) { + if (grouped.length < 2) continue; + const resolvedIds = new Set( + grouped.flatMap((candidate) => + candidate.resolved === null ? [] : [candidate.resolved.uid], + ), + ); + if (grouped.some((candidate) => candidate.resolved === null) || resolvedIds.size !== 1) { + ambiguousDataIdentities.add(identity); + } + } + + const out: ExtractedContract[] = []; + for (const { detection: d, filePath, pathNorm, resolved } of candidates) { + const contractId = contractIdFor(d.method, pathNorm); + if (d.framework === DATA_ROUTE_TABLE_SOURCE && ambiguousDataIdentities.has(contractId)) { + continue; + } + out.push({ + contractId, + type: 'http', + role: 'provider', + symbolUid: resolved?.uid ?? '', + symbolRef: { + filePath: resolved?.filePath || filePath, + name: resolved?.name ?? d.name ?? 'handler', + }, + symbolName: resolved?.name ?? d.name ?? 'handler', + confidence: d.confidence, + meta: { + method: d.method, + path: pathNorm, + pathSegments: pathNorm.split('/').filter(Boolean), + extractionStrategy: resolved ? 'source_scan_resolved' : 'source_scan', + framework: d.framework, + }, + }); + } return this.dedupeContracts(out); } @@ -911,6 +1044,12 @@ export class HttpRouteExtractor implements ContractExtractor { for (const d of detections) { if (d.role !== 'consumer') continue; const pathNorm = normalizeConsumerPath(d.path); + // A consumer url that cannot be reduced to a routable path (e.g. a + // leading template binding that is neither a clean prefix nor a + // remainder that starts with `/`) is dropped here rather than emitted + // as a never-matching contract — same treatment the plugins give + // static relative urls at scan time. + if (pathNorm === null) continue; // Resolve the function CONTAINING the fetch/axios call so the consumer // contract carries a real symbolUid (was always '' — the gap that left // cross-repo trace/impact unable to traverse HTTP links). diff --git a/gitnexus/src/core/group/extractors/java-workspace-extractor.ts b/gitnexus/src/core/group/extractors/java-workspace-extractor.ts index b6beed71c..fcbb06507 100644 --- a/gitnexus/src/core/group/extractors/java-workspace-extractor.ts +++ b/gitnexus/src/core/group/extractors/java-workspace-extractor.ts @@ -1,5 +1,6 @@ import fs from 'node:fs/promises'; import path from 'node:path'; +import { XMLParser } from 'fast-xml-parser'; import type { CypherExecutor } from '../contract-extractor.js'; import type { GroupManifestLink, ContractRole } from '../types.js'; import { shouldIgnorePath, loadIgnoreRules } from '../../../config/ignore-service.js'; @@ -20,6 +21,21 @@ interface ImportedSymbol { filePath: string; } +type XmlNode = Record; + +// POMs are static metadata. Parse hierarchy with a real XML parser, but do not +// invoke Maven or resolve the effective model. Properties, profiles, and remote +// parent resolution remain outside this extractor's deterministic boundary. +const pomParser = new XMLParser({ + ignoreAttributes: true, + removeNSPrefix: true, + trimValues: true, + parseTagValue: false, + processEntities: false, + ignoreDeclaration: true, + ignorePiTags: true, +}); + async function parseJavaManifest( repoPath: string, ): Promise<{ groupId: string; artifactId: string; deps: string[] } | null> { @@ -28,14 +44,15 @@ async function parseJavaManifest( const content = await fs.readFile(pomPath, 'utf-8'); return parsePom(content); } catch { - // fall through to Gradle + // Missing pom.xml — fall through to Gradle. } + const gradleSidecars = await readGradleSidecars(repoPath); for (const name of ['build.gradle.kts', 'build.gradle']) { const gradlePath = path.join(repoPath, name); try { const content = await fs.readFile(gradlePath, 'utf-8'); - return parseGradle(content, repoPath); + return parseGradle(content, repoPath, gradleSidecars); } catch { continue; } @@ -44,59 +61,286 @@ async function parseJavaManifest( return null; } -function parsePom(content: string): { groupId: string; artifactId: string; deps: string[] } | null { - const projectGroupMatch = content.match(/]*>[\s\S]*?([^<]+)<\/groupId>/); - const projectArtifactMatch = content.match( - /]*>[\s\S]*?([^<]+)<\/artifactId>/, - ); - if (!projectGroupMatch || !projectArtifactMatch) return null; +interface GradleSidecars { + propertiesGroup?: string; + rootProjectName?: string; + catalogLibraries: Map; + catalogBundles: Map; +} - const groupId = projectGroupMatch[1].trim(); - const artifactId = projectArtifactMatch[1].trim(); +async function readIfPresent(filePath: string): Promise { + try { + return await fs.readFile(filePath, 'utf-8'); + } catch { + return undefined; + } +} - const deps: string[] = []; - const depBlocks = content.matchAll(/\s*([\s\S]*?)<\/dependency>/g); - for (const block of depBlocks) { - const gMatch = block[1].match(/([^<]+)<\/groupId>/); - const aMatch = block[1].match(/([^<]+)<\/artifactId>/); - if (gMatch && aMatch) { - deps.push(`${gMatch[1].trim()}:${aMatch[1].trim()}`); +async function readGradleSidecars(repoPath: string): Promise { + const [properties, settingsKts, settingsGroovy, catalog] = await Promise.all([ + readIfPresent(path.join(repoPath, 'gradle.properties')), + readIfPresent(path.join(repoPath, 'settings.gradle.kts')), + readIfPresent(path.join(repoPath, 'settings.gradle')), + readIfPresent(path.join(repoPath, 'gradle', 'libs.versions.toml')), + ]); + + const sidecars: GradleSidecars = { + catalogLibraries: new Map(), + catalogBundles: new Map(), + }; + + const groupMatch = properties?.match(/(?:^|\n)\s*group\s*=\s*([^\s#]+)/); + if (groupMatch) sidecars.propertiesGroup = groupMatch[1]; + + const settings = settingsKts ?? settingsGroovy; + const nameMatch = settings?.match(/rootProject\.name\s*=\s*['"]([^'"]+)['"]/); + if (nameMatch) sidecars.rootProjectName = nameMatch[1]; + + if (catalog) { + const parsed = parseGradleVersionCatalog(catalog); + sidecars.catalogLibraries = parsed.libraries; + sidecars.catalogBundles = parsed.bundles; + } + + return sidecars; +} + +function catalogAccessors(alias: string): string[] { + const dotted = alias.replace(/[-_]/g, '.'); + const camel = alias.replace(/[-_]+([A-Za-z0-9])/g, (_, char: string) => char.toUpperCase()); + return [...new Set([alias, dotted, camel])]; +} + +function projectAccessorToArtifactId(accessor: string): string { + const last = accessor.split('.').pop()!; + return last.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`).replace(/^-/, ''); +} + +function moduleToGa(module: string): string | undefined { + const parts = module.split(':'); + return parts.length >= 2 ? `${parts[0]}:${parts[1]}` : undefined; +} + +function parseInlineTomlTable(rhs: string): Record { + const fields: Record = {}; + for (const match of rhs.matchAll(/([A-Za-z0-9_-]+)\s*=\s*['"]([^'"]+)['"]/g)) { + fields[match[1]] = match[2]; + } + return fields; +} + +/** Default Gradle catalog (`gradle/libs.versions.toml`) — aliases only, no version resolution. */ +function parseGradleVersionCatalog(toml: string): { + libraries: Map; + bundles: Map; +} { + const libraries = new Map(); + const bundles = new Map(); + let section: 'libraries' | 'bundles' | 'other' = 'other'; + + const addLibrary = (alias: string, ga: string) => { + for (const accessor of catalogAccessors(alias)) libraries.set(accessor, ga); + }; + + for (const raw of toml.split(/\r?\n/)) { + const line = raw.replace(/#.*$/, '').trim(); + if (!line) continue; + const header = line.match(/^\[([^\]]+)\]$/); + if (header) { + const name = header[1]; + section = + name === 'libraries' || name.endsWith('.libraries') + ? 'libraries' + : name === 'bundles' || name.endsWith('.bundles') + ? 'bundles' + : 'other'; + continue; + } + + if (section === 'libraries') { + const dottedModule = line.match(/^([A-Za-z0-9._-]+)\.module\s*=\s*['"]([^'"]+)['"]$/); + if (dottedModule) { + const ga = moduleToGa(dottedModule[2]); + if (ga) addLibrary(dottedModule[1], ga); + continue; + } + const assignment = line.match(/^([A-Za-z0-9._-]+)\s*=\s*(.+)$/); + if (!assignment) continue; + const alias = assignment[1]; + const rhs = assignment[2].trim(); + const quoted = rhs.match(/^['"]([^'"]+)['"]$/); + if (quoted) { + const ga = moduleToGa(quoted[1]); + if (ga) addLibrary(alias, ga); + continue; + } + const table = parseInlineTomlTable(rhs); + const ga = table.module + ? moduleToGa(table.module) + : table.group && table.name + ? `${table.group}:${table.name}` + : undefined; + if (ga) addLibrary(alias, ga); + continue; + } + + if (section === 'bundles') { + const assignment = line.match(/^([A-Za-z0-9._-]+)\s*=\s*\[([^\]]*)\]$/); + if (!assignment) continue; + const members = [...assignment[2].matchAll(/['"]([^'"]+)['"]/g)].map((match) => match[1]); + for (const accessor of catalogAccessors(assignment[1])) bundles.set(accessor, members); } } + return { libraries, bundles }; +} + +const GRADLE_GROUP_PATTERNS = [ + /(?:^|[\n{;])\s*(?:rootProject\.)?group\s*=\s*['"]([^'"]+)['"]/, + /(?:^|[\n{;])\s*group\s+['"]([^'"]+)['"]/, +]; + +const GRADLE_COORD_CONFIGS = + 'implementation|api|compileOnly|runtimeOnly|testImplementation|testApi|testCompileOnly|compile|kapt|ksp|commonMainImplementation|commonMainApi'; + +const CATALOG_ALIAS = '([A-Za-z0-9_]+(?:\\.[A-Za-z0-9_]+)*)(?:\\.get\\(\\)|\\.asProvider\\(\\))?'; + +function gradleDepRe(suffix: string): RegExp { + return new RegExp(`(?:${GRADLE_COORD_CONFIGS})\\s*${suffix}`, 'g'); +} + +function parseGradleGroup(content: string): string | undefined { + for (const pattern of GRADLE_GROUP_PATTERNS) { + const match = content.match(pattern); + if (match?.[1]) return match[1]; + } + return undefined; +} + +function asXmlNode(value: unknown): XmlNode | undefined { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as XmlNode) + : undefined; +} + +function xmlText(value: unknown): string | undefined { + if (typeof value === 'string' || typeof value === 'number') { + const text = String(value).trim(); + return text || undefined; + } + const nested = asXmlNode(value)?.['#text']; + if (nested === undefined) return undefined; + return xmlText(nested); +} + +function xmlChildText(node: XmlNode | undefined, name: string): string | undefined { + return node ? xmlText(node[name]) : undefined; +} + +function asList(value: unknown): unknown[] { + if (value === undefined || value === null) return []; + return Array.isArray(value) ? value : [value]; +} + +/** Direct project dependencies only — not BOM, profiles, or plugin classpath. */ +function collectProjectDependencies(project: XmlNode, deps: string[]): void { + const dependencies = asXmlNode(project.dependencies); + if (!dependencies) return; + for (const dep of asList(dependencies.dependency)) { + const depNode = asXmlNode(dep); + const groupId = xmlChildText(depNode, 'groupId'); + const artifactId = xmlChildText(depNode, 'artifactId'); + if (groupId && artifactId) deps.push(`${groupId}:${artifactId}`); + } +} + +function parsePom(content: string): { groupId: string; artifactId: string; deps: string[] } | null { + let parsed: unknown; + try { + // parseSourceSafe guards tree-sitter's Windows SIGSEGV by switching to a + // chunked input callback above 16 KB; XMLParser only accepts XML text, so + // routing POMs through it silently yields an empty document. + // eslint-disable-next-line gitnexus/require-safe-parse + parsed = pomParser.parse(content); + } catch { + return null; + } + + const project = asXmlNode(asXmlNode(parsed)?.project); + if (!project) return null; + + // Maven inherits groupId from , but artifactId is always the + // project's own direct child and must never fall back to parent.artifactId. + const groupId = + xmlChildText(project, 'groupId') ?? xmlChildText(asXmlNode(project.parent), 'groupId'); + const artifactId = xmlChildText(project, 'artifactId'); + if (!groupId || !artifactId) return null; + + const deps: string[] = []; + collectProjectDependencies(project, deps); return { groupId, artifactId, deps: [...new Set(deps)] }; } function parseGradle( content: string, repoPath: string, + sidecars: GradleSidecars = { catalogLibraries: new Map(), catalogBundles: new Map() }, ): { groupId: string; artifactId: string; deps: string[] } | null { - const groupMatch = content.match(/group\s*=\s*['"]([^'"]+)['"]/); - const dirName = path.basename(repoPath); - const groupId = groupMatch ? groupMatch[1] : ''; + // Static text + default catalog file. Do not execute Gradle. + const groupId = parseGradleGroup(content) ?? sidecars.propertiesGroup ?? ''; if (!groupId) return null; - const artifactId = dirName; + const artifactId = sidecars.rootProjectName ?? path.basename(repoPath); + const { catalogLibraries, catalogBundles } = sidecars; const deps: string[] = []; - // implementation("group:artifact:version") or api("group:artifact:version") - const depMatches = content.matchAll( - /(?:implementation|api|compileOnly|runtimeOnly)\s*\(\s*['"]([^'"]+)['"]\s*\)/g, + const pushCatalogAlias = (alias: string) => { + const ga = catalogLibraries.get(alias); + if (ga) deps.push(ga); + }; + + const namedPattern = gradleDepRe( + `(?:\\(\\s*)?(?:group\\s*=\\s*['"](?[^'"]+)['"]\\s*,\\s*name\\s*=\\s*['"](?[^'"]+)['"]|name\\s*=\\s*['"](?[^'"]+)['"]\\s*,\\s*group\\s*=\\s*['"](?[^'"]+)['"]|group:\\s*['"](?[^'"]+)['"]\\s*,\\s*name:\\s*['"](?[^'"]+)['"]|name:\\s*['"](?[^'"]+)['"]\\s*,\\s*group:\\s*['"](?[^'"]+)['"])`, ); - for (const m of depMatches) { - const parts = m[1].split(':'); - if (parts.length >= 2) { - deps.push(`${parts[0]}:${parts[1]}`); + for (const match of content.matchAll(namedPattern)) { + const group = + match.groups?.group1 ?? match.groups?.group2 ?? match.groups?.group3 ?? match.groups?.group4; + const name = + match.groups?.name1 ?? match.groups?.name2 ?? match.groups?.name3 ?? match.groups?.name4; + if (group && name) deps.push(`${group}:${name}`); + } + + for (const match of content.matchAll( + gradleDepRe(`(?:\\(\\s*)?libs(?:\\.libraries)?\\.(?!bundles\\.|plugins\\.)${CATALOG_ALIAS}`), + )) { + pushCatalogAlias(match[1]); + } + + for (const match of content.matchAll( + gradleDepRe(`(?:\\(\\s*)?libs\\.bundles\\.${CATALOG_ALIAS}`), + )) { + for (const member of catalogBundles.get(match[1]) ?? []) { + for (const accessor of catalogAccessors(member)) pushCatalogAlias(accessor); } } - // implementation(project(":subproject")) - const projDeps = content.matchAll( - /(?:implementation|api)\s*\(\s*project\s*\(\s*['"]([^'"]+)['"]\s*\)\s*\)/g, - ); - for (const m of projDeps) { - const subName = m[1].replace(/^:/, ''); - deps.push(`${groupId}:${subName}`); + for (const match of content.matchAll(gradleDepRe(`\\(\\s*projects\\.([A-Za-z][A-Za-z0-9.]*)`))) { + deps.push(`${groupId}:${projectAccessorToArtifactId(match[1])}`); + } + + for (const match of content.matchAll( + gradleDepRe(`(?:\\(\\s*['"]([^'"]+)['"]\\s*\\)|['"]([^'"]+)['"])`), + )) { + const coord = match[1] ?? match[2]; + if (!coord) continue; + const parts = coord.split(':'); + if (parts.length >= 2) deps.push(`${parts[0]}:${parts[1]}`); + } + + for (const match of content.matchAll( + gradleDepRe(`(?:\\(\\s*)?project\\s*\\(\\s*['"]([^'"]+)['"]\\s*\\)`), + )) { + deps.push(`${groupId}:${match[1].replace(/^:/, '')}`); } return { groupId, artifactId, deps: [...new Set(deps)] }; diff --git a/gitnexus/src/core/group/extractors/manifest-extractor.ts b/gitnexus/src/core/group/extractors/manifest-extractor.ts index 272c27ce6..d4175442a 100644 --- a/gitnexus/src/core/group/extractors/manifest-extractor.ts +++ b/gitnexus/src/core/group/extractors/manifest-extractor.ts @@ -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 diff --git a/gitnexus/src/core/group/extractors/python-workspace-extractor.ts b/gitnexus/src/core/group/extractors/python-workspace-extractor.ts index 4453852a6..c07e5d8cc 100644 --- a/gitnexus/src/core/group/extractors/python-workspace-extractor.ts +++ b/gitnexus/src/core/group/extractors/python-workspace-extractor.ts @@ -2,7 +2,11 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import type { CypherExecutor } from '../contract-extractor.js'; import type { GroupManifestLink, ContractRole } from '../types.js'; -import { shouldIgnorePath, loadIgnoreRules } from '../../../config/ignore-service.js'; +import { + shouldIgnorePath, + loadIgnoreRules, + isHardcodedIgnoredDirectoryAtPath, +} from '../../../config/ignore-service.js'; import { logger } from '../../logger.js'; interface PythonPackageMeta { @@ -161,9 +165,11 @@ async function findPythonFiles(repoPath: string): Promise { for (const entry of entries) { const childRel = rel ? `${rel}/${entry.name}` : entry.name; if (entry.isDirectory()) { + const childPath = path.join(dir, entry.name); if (shouldIgnorePath(childRel)) continue; + if (isHardcodedIgnoredDirectoryAtPath(repoPath, childPath)) continue; if (ig && ig.ignores(childRel + '/')) continue; - await walk(path.join(dir, entry.name), childRel); + await walk(childPath, childRel); } else if (entry.name.endsWith('.py')) { if (shouldIgnorePath(childRel)) continue; if (ig && ig.ignores(childRel)) continue; diff --git a/gitnexus/src/core/group/group-lock.ts b/gitnexus/src/core/group/group-lock.ts new file mode 100644 index 000000000..cdaacb283 --- /dev/null +++ b/gitnexus/src/core/group/group-lock.ts @@ -0,0 +1,200 @@ +/** + * Cross-process single-writer lock for one group's persisted state (R9). + * + * A group sync ends by REPLACING `contracts.json` and rebuilding `bridge.lbug` + * from a snapshot it computed minutes earlier. Two syncs of the same group that + * overlap therefore do not merge — the second one's write simply overwrites the + * first one's, and whichever finishes last wins with a registry assembled from + * repo state the other run never saw. Nothing detects it afterwards: both runs + * report success, and the group's contracts silently describe a mixture that was + * never true at any instant. This module serializes that section so one sync at + * a time can be inside it. + * + * WHERE THE LOCK LIVES. On a dedicated `sync-lock` directory INSIDE the group + * directory — mirroring `withRegistryLock`, which locks a `registry-lock` + * directory beside the registry rather than the registry's own directory + * (repo-manager.ts). {@link acquireIndexLock} is NOT reentrant and its file + * backend writes `analyze.lock` into the directory it is handed, so pointing it + * at a directory that some other code path might also lock — or that already + * holds a per-repo index slot — reintroduces exactly the collision the registry + * lock's own comment warns about. `/sync-lock` is a namespace nothing + * else claims: group directories live under `~/.gitnexus/groups/` (or + * `$GITNEXUS_HOME`), never under a repo's `.gitnexus[/branches/]`. + * + * WHY IT FAILS CLOSED, like the registry lock. `withRegistryLock` also + * refuses to continue unlocked on timeout: a lost registry update can drop a + * concurrent registration. A group sync still fails closed for additional + * reasons — it is long, expensive, operator-initiated, and a lost update + * destroys contracts rather than a registry field. + * A sync that cannot be protected must not run at all, and there are three + * distinct ways it can fail to be protected; all three throw + * {@link GroupSyncLockError}: + * + * 1. TIMEOUT — the holder is still alive when the ceiling elapses. + * 2. LOCK-FREE DEGRADATION — `acquireIndexLock` answers a read-only or + * permission-denied filesystem with a no-op handle that is byte-identical + * to a real one at the API boundary. That is a deliberate tolerance for + * `analyze` (an unwritable index dir rejects every write anyway, so the + * lock is moot), but here it would hand back a handle that protects + * nothing while the sync went on to attempt its writes. The handle now + * carries {@link IndexLockHandle.lockFree}, so we can see it and refuse. + * 3. ANY OTHER ACQUIRE FAILURE — e.g. `sync-lock` cannot be created because a + * regular file already occupies the path. Silently proceeding on an error + * we did not anticipate is the same unprotected run under another name. + * + * WHY THE CEILING IS PASSED EXPLICITLY. The magnitude is not the point — 10 + * minutes deliberately matches `acquireIndexLock`'s own default, because a group + * sync is analyze-shaped and a legitimately queued second sync must be able to + * wait out a full first one (the registry lock's 5s is sized for a sub-second + * merge and is the wrong model here). The reason to pass it is + * `resolveTimeoutMs`: it prefers an explicit argument over + * `GITNEXUS_INDEX_LOCK_TIMEOUT_MS`, and that variable's `<= 0` case resolves to + * `Number.POSITIVE_INFINITY`. Inheriting it would let an environment turn this + * lock's fail-closed timeout into an unbounded hang. + * + * ACQUIRED EXACTLY ONCE, by `syncGroup`, around its whole persist section. + * Nothing it calls beneath that point — `writeContractRegistry`, + * `refreshPreservedBridgeMeta`, `writeBridgeUnlocked` — takes this lock; a + * second acquisition would deadlock a non-reentrant primitive on the HAPPY + * path, not on some edge case. `bridge-db.ts` exports the swap in both forms + * for exactly that reason: `writeBridgeUnlocked` for the held-lock caller + * (`syncGroup`), and the `writeBridge` wrapper, which acquires here, for direct + * callers that are outside the region. The same split `repo-manager.ts` uses + * for `registerRepoUnlocked` / `registerRepo`. + * + * SCOPE CAVEAT (recorded, not solved): the default socket backend uses Linux + * abstract sockets, which are network-namespace-scoped. Two containers that + * share a bind-mounted group directory but sit in separate netns will NOT + * contend, exactly as documented for the index lock itself; forcing + * `GITNEXUS_INDEX_LOCK_BACKEND=file` is what covers that deployment. + */ +import path from 'node:path'; +import { + acquireIndexLock, + IndexLockTimeoutError, + type IndexLockHandle, +} from '../../storage/index-lock.js'; +import { logger } from '../logger.js'; + +/** Lock-directory name inside the group directory. Never the group dir itself. */ +export const GROUP_SYNC_LOCK_DIRNAME = 'sync-lock'; + +/** The dedicated lock namespace for one group: `/sync-lock`. */ +export const getGroupSyncLockDir = (groupDir: string): string => + path.join(groupDir, GROUP_SYNC_LOCK_DIRNAME); + +/** + * Wait ceiling for the group sync lock (10 min). See the module header: the + * magnitude matches `acquireIndexLock`'s analyze-sized default on purpose; the + * reason it is passed EXPLICITLY is to keep `GITNEXUS_INDEX_LOCK_TIMEOUT_MS` + * (whose `<= 0` case means unbounded) from turning fail-closed into a hang. + */ +export const GROUP_SYNC_LOCK_TIMEOUT_MS = 600_000; + +/** Which of the three fail-closed exits produced a {@link GroupSyncLockError}. */ +export type GroupSyncLockFailure = 'timeout' | 'lock-free' | 'unavailable'; + +/** + * A group sync could not be protected, so it did not run. One class for all + * three exits so both callers — the CLI command and the MCP service — have a + * single thing to catch and report. + */ +export class GroupSyncLockError extends Error { + readonly reason: GroupSyncLockFailure; + readonly groupDir: string; + constructor(reason: GroupSyncLockFailure, groupDir: string, message: string, cause?: unknown) { + super(message, cause === undefined ? undefined : { cause }); + this.name = 'GroupSyncLockError'; + this.reason = reason; + this.groupDir = groupDir; + } +} + +/** + * Run `operation` as the only group sync touching `groupDir`, or throw + * {@link GroupSyncLockError} without running it at all. + * + * The lock is released in a `finally`, so it is dropped whether the operation + * succeeds or throws. + */ +export const withGroupSyncLock = async ( + groupDir: string, + operation: () => Promise, +): Promise => { + let handle: IndexLockHandle; + // The wrapper times the acquisition itself. `IndexLockTimeoutError` carries + // `holder` and `holderKnown` and nothing else — the elapsed wait exists only + // inside its inherited message string, so the figure has to be measured here + // to be reported without that message. `Date.now()` matches how the primitive + // measures its own wait. + const acquireStartedAt = Date.now(); + try { + handle = await acquireIndexLock(getGroupSyncLockDir(groupDir), { + timeoutMs: GROUP_SYNC_LOCK_TIMEOUT_MS, + // `acquireIndexLock`'s own `log` texts name an "analyze" holder, which + // misattributes a group-sync wait — the same reason `withRegistryLock` + // supplies its own line instead of passing `log` through. + onWaitStart: () => + logger.info( + { groupDir }, + 'Waiting for another GitNexus process to finish syncing this group…', + ), + }); + } catch (err) { + // The inherited message names "another gitnexus analyze" as the holder — + // a cause this detection path cannot establish. Nothing but a group sync + // ever locks `/sync-lock` (see the module header), and on the + // socket backend the holder is not identifiable at all. Re-word it around + // what IS known: which group, which operation, and how long we waited. + if (err instanceof IndexLockTimeoutError) { + throw new GroupSyncLockError( + 'timeout', + groupDir, + `Timed out after ${Date.now() - acquireStartedAt}ms waiting for the sync lock on ` + + `group "${path.basename(groupDir)}" (${getGroupSyncLockDir(groupDir)}). ` + + // `holderKnown` is false on the socket backend and on the file + // backend's malformed/vanished-lock timeouts, where `holder` is a + // placeholder (`pid -1`). Presenting that as a real owner would be the + // same unestablished claim in a new form. + (err.holderKnown + ? `Held by pid ${err.holder.pid} on ${err.holder.hostname} ` + + `(invocation ${err.holder.invocationId}). ` + : `The lock stayed held for the whole wait, but this lock backend ` + + `cannot identify the holder. `) + + `Nothing was written and this group was not synced. ` + + `Re-run once the other sync of this group has finished.`, + err, + ); + } + throw new GroupSyncLockError( + 'unavailable', + groupDir, + `Could not acquire the sync lock for this group (${getGroupSyncLockDir(groupDir)}): ` + + `${err instanceof Error ? err.message : String(err)}. Nothing was written.`, + err, + ); + } + + if (handle.lockFree) { + // A handle that owns nothing. Release it anyway (it is a no-op, but the + // contract is that every handle is released) and refuse to run: this sync + // would otherwise write `contracts.json` and `bridge.lbug` with no + // protection at all against a concurrent sync doing the same. + handle.release(); + throw new GroupSyncLockError( + 'lock-free', + groupDir, + `The sync lock for this group could not be created at ` + + `${getGroupSyncLockDir(groupDir)} (read-only or permission-denied filesystem), ` + + `so this sync cannot be protected against a concurrent one. Nothing was written. ` + + `Make the group directory writable and re-run.`, + undefined, + ); + } + + try { + return await operation(); + } finally { + handle.release(); + } +}; diff --git a/gitnexus/src/core/group/matching.ts b/gitnexus/src/core/group/matching.ts index eea6fc102..2647cc009 100644 --- a/gitnexus/src/core/group/matching.ts +++ b/gitnexus/src/core/group/matching.ts @@ -37,7 +37,13 @@ function buildNoisyContractFilter( : new Set(); 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; diff --git a/gitnexus/src/core/group/normalization.ts b/gitnexus/src/core/group/normalization.ts index c99d36850..50102415a 100644 --- a/gitnexus/src/core/group/normalization.ts +++ b/gitnexus/src/core/group/normalization.ts @@ -91,6 +91,61 @@ function crossLinkKey(link: CrossLink): string { ].join('\0'); } +/** + * True when a link endpoint carries no resolved graph symbol — empty + * `symbolUid` or a missing/empty `symbolRef`. + * + * Sync marks a cross-link `degraded: true` when this holds for the PROVIDER + * endpoint (`to`): the contract boundary is proven, but the empty uid can + * never match a Phase-1 impact symbol id, so cross-repo fan-out across the + * link silently yields nothing (the classic case is a provider whose handler + * failed to resolve, leaving `symbolName` degraded to the file name with one + * pseudo-symbol carrying every route in that file). Consumer-side (`from`) + * emptiness is deliberately NOT degraded — several extractors (topics, grpc) + * legitimately emit consumer contracts without a per-call symbol, and the + * anchor that matters for far-side fan-out is the provider's. + * + * Kept next to the endpoint merge logic because `dedupeCrossLinks` must + * re-derive the flag after a merge: `mergeEndpoints` backfills `symbolUid` + * from the losing twin, which can invalidate a flag carried in from the winner. + * + * NOT unresolved: a deterministic `manifest::::` synthetic + * uid (see `manifestSymbolUid`). Manifest endpoints fall back to it precisely + * when the graph has no symbol for them — its empty `symbolRef.filePath` would + * otherwise trip the check below — yet cross-impact anchors those links by + * design (#2722: the crossing is preserved with `fanout_status: + * 'not_attempted'` instead of silently yielding cross=0). The prefix is the + * canonical discriminator — real indexer uids never start with `manifest::` + * — and `cross-impact.ts` branches on the same test. Encoding the exemption + * HERE (not at the sync marking call site) keeps marking and the post-merge + * re-derivation from drifting apart, and keeps the flag's meaning exactly what + * `types.ts` documents: "distinct from manifest::… synthetic UIDs". + */ +export function isUnresolvedEndpoint(endpoint: CrossLinkEndpoint): boolean { + if (endpoint.symbolUid.startsWith('manifest::')) return false; + return ( + !endpoint.symbolUid || + !endpoint.symbolRef || + !endpoint.symbolRef.filePath || + !endpoint.symbolRef.name + ); +} + +/** + * Derive `degraded` from the provider endpoint. Present (`true`) only when + * unresolved; deleted otherwise so contracts.json stays "carried only when + * meaningful" (`'degraded' in link === false` for anchored links). + */ +export function applyDegradedFlag(link: CrossLink): CrossLink { + const next: CrossLink = { ...link }; + if (isUnresolvedEndpoint(next.to)) { + next.degraded = true; + } else { + delete next.degraded; + } + return next; +} + export function dedupeContracts(items: StoredContract[]): StoredContract[] { const deduped = new Map(); for (const contract of items) { @@ -113,12 +168,15 @@ export function dedupeCrossLinks(items: CrossLink[]): CrossLink[] { const keepIncoming = link.confidence > existing.confidence; const primary = keepIncoming ? link : existing; const secondary = keepIncoming ? existing : link; - deduped.set(key, { + const merged: CrossLink = { ...primary, confidence: Math.max(existing.confidence, link.confidence), from: mergeEndpoints(primary.from, secondary.from), to: mergeEndpoints(primary.to, secondary.to), - }); + }; + // Re-derive after mergeEndpoints: a richer twin can backfill `to.symbolUid` + // and must not leave a stale `degraded` flag on an now-anchored link. + deduped.set(key, applyDegradedFlag(merged)); } - return [...deduped.values()]; + return [...deduped.values()].map(applyDegradedFlag); } diff --git a/gitnexus/src/core/group/service.ts b/gitnexus/src/core/group/service.ts index f1356dcec..6f41d6586 100644 --- a/gitnexus/src/core/group/service.ts +++ b/gitnexus/src/core/group/service.ts @@ -6,7 +6,16 @@ import fsp from 'node:fs/promises'; import path from 'node:path'; import { checkStaleness } from '../git-staleness.js'; -import { loadMeta, type RepoMeta } from '../../storage/repo-manager.js'; +import { + canonicalizePath, + loadMeta, + readRegistryStrict, + registryPathEquals, + type RegistryEntry, + type RepoMeta, +} from '../../storage/repo-manager.js'; +import { crossRepoCompleteness } from './completeness.js'; +import { recordedMatchStages, recordedRepoList } from './completeness.js'; import { GroupNotFoundError, loadGroupConfig } from './config-parser.js'; import { fileMatchesServicePrefix, @@ -42,6 +51,17 @@ export interface GroupToolPort { repo: GroupRepoHandle, params: { target: string; + /** + * Target-selector params, same semantics as the single-repo `impact` + * tool: `target_uid` is the zero-ambiguity lookup (it wins over the + * name), `file_path`/`kind` narrow a name shared by several symbols + * (e.g. same-named Api/Impl/Controller layers). The port implementation + * consumes them directly; the Phase-1 caller in cross-impact.ts is + * responsible for threading them from the MCP `impact` args. + */ + target_uid?: string; + file_path?: string; + kind?: string; direction: 'upstream' | 'downstream'; maxDepth?: number; relationTypes?: string[]; @@ -222,10 +242,39 @@ function isCrossLink(raw: unknown): raw is CrossLink { return typeof o.contractId === 'string' && typeof o.type === 'string'; } +/** + * Does the global registry hold a row for this configured group member? + * + * Consulted only once resolution has ALREADY failed, to choose which of the + * two failures `group status` reports. It mirrors the two tiers + * `LocalBackend.resolveRepo` matches a bare group-config value on — the + * registry `name`, case-insensitively, and the repo `path` — and deliberately + * stops short of its hashed-id and partial-name tiers: those exist to be + * generous about what an operator typed, while this predicate only decides + * between two labels, and a looser match here would relabel a genuine registry + * miss as an unresolvable row. That is the same conflation this reporting + * exists to remove, pointed the other way. + */ +function registryIdentifies(entries: RegistryEntry[], registryName: string): boolean { + const wantedName = registryName.toLowerCase(); + // Path equality goes through the registry's own rule rather than a local + // `resolve` + platform-case compare. `canonicalizePath` also follows symlinks, + // so a row registered through one and looked up through the other still + // matches — and there is one definition of registry path identity instead of + // a third, weaker copy of it living in a group module nobody would grep. + const wantedPath = canonicalizePath(registryName); + return entries.some((entry) => { + if (typeof entry.name === 'string' && entry.name.toLowerCase() === wantedName) return true; + if (typeof entry.path !== 'string') return false; + return registryPathEquals(canonicalizePath(entry.path), wantedPath); + }); +} + 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; @@ -288,6 +337,17 @@ 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 : '', @@ -295,12 +355,89 @@ async function loadContractRegistryResilient( base.repoSnapshots && typeof base.repoSnapshots === 'object' && base.repoSnapshots !== null ? (base.repoSnapshots as Record) : {}, - missingRepos: Array.isArray(base.missingRepos) ? (base.missingRepos as string[]) : [], + // Same gate as `groupStatus` uses on the same field, for the same reason: + // `Array.isArray` alone waves through `[{repo:'x'}]`, and `groupContracts` + // now returns this list AND folds it into its completeness answer, so a + // value we could not read would be reported as a repo name. `missingRepos` + // has always been required, so — unlike `unreadableRepos` below — there is + // no "not recorded" state to preserve: an unreadable value degrades to empty. + missingRepos: recordedRepoList(base.missingRepos) ?? [], + // Spread, not `?? []`. `ContractRegistry.unreadableRepos` documents absence + // as "not recorded", and a registry written before the field existed has no + // opinion about which indexes were readable. Normalizing that to `[]` hands + // the caller "the last sync found none unreadable" — an unmeasured state + // 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): { 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 { @@ -332,6 +469,13 @@ export class GroupService { async groupSync(params: Record): Promise { 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 { @@ -346,19 +490,52 @@ 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 result = await syncGroup(config, { - groupDir, - exactOnly: Boolean(params.exactOnly), - skipEmbeddings: Boolean(params.skipEmbeddings), - allowStale: Boolean(params.allowStale), - verbose: Boolean(params.verbose), - }); + 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>; + try { + result = await syncGroup(config, { + groupDir, + 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, + // which an agent would read as "the group genuinely has no contracts". + if (!(err instanceof GroupSyncLockError)) throw err; + return { error: err.message }; + } return { contracts: result.contracts.length, crossLinks: result.crossLinks.length, 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. + registryOutcome: result.registryOutcome, + // Data-quality signals surfaced from the sync run: links whose provider + // endpoint never resolved to a graph symbol, per-repo extraction + // failures with reasons, and operator warnings (e.g. bridge.lbug write + // failed after contracts.json was written). Always present so MCP + // consumers can branch on them without existence checks. + degradedLinks: result.degradedLinks, + failedRepos: result.failedRepos, + warnings: result.warnings, }; } @@ -386,7 +563,50 @@ export class GroupService { ); contracts = contracts.filter((c) => !matchedIds.has(`${c.repo}::${c.contractId}`)); } - const out: Record = { contracts, crossLinks: registry.crossLinks }; + // `loadContractRegistryResilient` already applied `recordedRepoList` to + // both: `undefined` here is "the last sync recorded no opinion" (a registry + // written before the field existed, or a value we could not read), which is + // NOT the same answer as the measured empty list. + const { unreadableRepos, missingRepos } = registry; + // `incompleteRepos` is dropped on this surface only because the two lists it + // is derived from are returned verbatim right below; the truncation triple is + // the part that has no other channel here. + 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. + // 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, + // not which repos the sync had to read to produce them. + inScope: () => true, + }); + const out: Record = { + contracts, + crossLinks: registry.crossLinks, + missingRepos, + // Omitted rather than `[]` when the registry never recorded it — the same + // 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, + }; if (skippedCorrupt > 0) out.skippedCorrupt = skippedCorrupt; return out; } @@ -573,17 +793,80 @@ export class GroupService { } const registry = await readContractRegistry(groupDir); + /** + * The STRICT global-registry read, deliberately — this is the one caller + * that has to tell "the registry says nothing about this repo" apart from + * "the registry could not be read at all", and only the strict mode can. + * `readRegistry`'s `catch { return [] }` collapses a malformed registry + * into an empty one, which is indistinguishable from a genuine absence and + * would report every configured repo as having no entry — the exact + * conflation the two labels below exist to remove. + * + * The consequence is accepted knowingly: the strict read rejects the WHOLE + * registry when any single row fails to identify a repo, so one malformed + * row renders every member of the group unresolvable, including members + * whose own rows are fine. That is the honest verdict — a registry the + * resolver cannot trust row-wise cannot be trusted about any row — and it + * is reported as an unresolved state, never as a clean one. + * + * ENOENT is not a failure in either mode: no registry file genuinely means + * nothing has been registered yet, so every repo is legitimately missing. + */ + let registryEntries: RegistryEntry[] | null = null; + let registryReadError: string | null = null; + try { + registryEntries = await readRegistryStrict(); + } catch (err) { + registryReadError = err instanceof Error ? err.message : String(err); + } + const repoStatuses: Record< string, { indexStale: boolean; contractsStale: boolean; + /** + * Unchanged meaning: this repo has no usable status. It stays `true` + * for BOTH failures below, so a consumer written before the split + * still sees every unusable repo flagged. Reporting an unresolvable + * repo as `missing: false` would hand that consumer `indexStale: + * false` for a repo nothing was ever read from — a false all-clear. + */ missing: boolean; + /** + * Which failure `missing` means: `false` is a genuine registry miss, + * `true` is an entry the resolver could not turn into a repo. Additive + * — always present on every row, so an agent can branch on it without + * having to treat an absent key as either answer. + */ + unresolvable: boolean; + /** Set only when `unresolvable`; says what could not be resolved. */ + unresolvableReason?: string; commitsBehind?: number; } > = {}; for (const [repoPath, registryName] of Object.entries(config.repos)) { + if (registryEntries === null) { + repoStatuses[repoPath] = { + indexStale: false, + contractsStale: false, + missing: true, + unresolvable: true, + unresolvableReason: `the global registry could not be read: ${registryReadError}`, + }; + continue; + } + // Only `resolveRepo` is inside the try that produces the + // "did not resolve" label, so the label is earned rather than assumed. + // `loadMeta` and `checkStaleness` cannot throw — the first returns null on + // every error, the second catches everything — but the reading below them + // can, and did: `registry.repoSnapshots` is read off a bare + // `JSON.parse(...) as ContractRegistry` with no shape check, so a + // contracts.json missing that field threw a TypeError into this catch and + // reported every repo as an unresolvable GLOBAL-registry entry. That sent + // the operator to repair the wrong file. The optional chain below closes + // the crash; this split stops the next one being mislabelled the same way. try { const repoObj = await this.port.resolveRepo(registryName); const meta: Partial> = @@ -593,7 +876,7 @@ export class GroupService { ? checkStaleness(repoObj.repoPath, meta.lastCommit) : { isStale: true, commitsBehind: -1 }; - const snapshot = registry?.repoSnapshots[repoPath]; + const snapshot = registry?.repoSnapshots?.[repoPath]; const contractsStale = snapshot && meta.indexedAt ? snapshot.indexedAt !== meta.indexedAt : !snapshot; @@ -601,17 +884,49 @@ export class GroupService { indexStale: staleness.isStale, contractsStale: Boolean(contractsStale), missing: false, + unresolvable: false, commitsBehind: staleness.commitsBehind, }; - } catch { - repoStatuses[repoPath] = { indexStale: false, contractsStale: false, missing: true }; + } catch (err) { + // The registry read succeeded, so its answer about this row is + // trustworthy: a row that is there and still would not resolve is a + // different fact from a row that was never there, and the operator's + // next move differs (repair the entry vs. index the repo). + const known = registryIdentifies(registryEntries, registryName); + const reason = err instanceof Error ? err.message : String(err); + repoStatuses[repoPath] = { + indexStale: false, + contractsStale: false, + missing: true, + unresolvable: known, + ...(known + ? { unresolvableReason: `registry entry "${registryName}" did not resolve: ${reason}` } + : {}), + }; } } return { group: name, lastSync: registry?.generatedAt || null, - missingRepos: registry?.missingRepos || [], + // `readContractRegistry` is a bare `JSON.parse(...) as ContractRegistry`, + // so both of these are whatever the file happened to hold — the + // validation in `loadContractRegistryResilient` never runs on this path. + // A `contracts.json` carrying a string here reached `cli/group.ts` and + // died in `.join(', ')`, i.e. an unreadable registry crashing the command + // whose job is to explain unreadable things. + // + // `missingRepos` has always been required, so there is no "not recorded" + // state to preserve for it — an unreadable value degrades to empty. + missingRepos: recordedRepoList(registry?.missingRepos) ?? [], + // `unreadableRepos` does have one: absent means "not recorded", not + // "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, }; } diff --git a/gitnexus/src/core/group/storage.ts b/gitnexus/src/core/group/storage.ts index b23f48d68..e196095ef 100644 --- a/gitnexus/src/core/group/storage.ts +++ b/gitnexus/src/core/group/storage.ts @@ -2,20 +2,10 @@ import * as fs from 'node:fs'; import * as fsp from 'node:fs/promises'; import * as path from 'node:path'; import * as os from 'node:os'; -import { randomBytes } from 'node:crypto'; import type { ContractRegistry } from './types.js'; -import { retryRename } from '../../storage/fs-atomic.js'; +import { writeFileAtomic } from '../../storage/fs-atomic.js'; -/** - * Build an unpredictable suffix for atomic-write tmp files. Replaces the - * previous `Date.now()` pattern which CodeQL flagged as - * js/insecure-temporary-file: a guessable suffix in a writable directory - * lets a co-located attacker pre-create or symlink the tmp path before the - * write lands. - */ -const tmpSuffix = (): string => randomBytes(8).toString('hex'); - -const CONTRACTS_FILE = 'contracts.json'; +export const CONTRACTS_FILE = 'contracts.json'; export function getDefaultGitnexusDir(): string { return process.env.GITNEXUS_HOME || path.join(os.homedir(), '.gitnexus'); @@ -40,33 +30,16 @@ export function getGroupDir(gitnexusDir: string, groupName: string): string { return path.join(gitnexusDir, 'groups', groupName); } +/** The registry path, so callers that stat or watch the file do not respell its name. */ +export function getContractRegistryPath(groupDir: string): string { + return path.join(groupDir, CONTRACTS_FILE); +} + export async function writeContractRegistry( groupDir: string, registry: ContractRegistry, ): Promise { - const targetPath = path.join(groupDir, CONTRACTS_FILE); - const tmpPath = `${targetPath}.tmp.${tmpSuffix()}`; - - // O_EXCL via `'wx'` flag + explicit `0o600` mode — closes both halves - // of the CodeQL js/insecure-temporary-file finding: `'wx'` rejects a - // pre-planted symlink at the path, and `0o600` (user-only) prevents - // the file from being created group/world readable while it briefly - // contains contract data en route to the rename. The query's - // `isSecureMode` predicate inspects ONLY the mode argument, not the - // flags, so the explicit mode is what credits the fix. - const handle = await fsp.open(tmpPath, 'wx', 0o600); - try { - await handle.writeFile(JSON.stringify(registry, null, 2), 'utf-8'); - } finally { - await handle.close(); - } - // retryRename absorbs the documented Windows EPERM/EBUSY/EACCES race that - // fires when AV scanners or another concurrent rename briefly hold the - // destination handle between rename calls. Same helper bridge-db.ts uses - // (lines 304, 583, 587, 595, 605, 677) for the bridge.lbug atomic swap — - // single source of truth for the Windows-rename pattern across the group - // package. - await retryRename(tmpPath, targetPath); + await writeFileAtomic(path.join(groupDir, CONTRACTS_FILE), JSON.stringify(registry, null, 2)); } export async function readContractRegistry(groupDir: string): Promise { @@ -123,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 `; diff --git a/gitnexus/src/core/group/sync.ts b/gitnexus/src/core/group/sync.ts index a329500be..de7ce5c9e 100644 Binary files a/gitnexus/src/core/group/sync.ts and b/gitnexus/src/core/group/sync.ts differ diff --git a/gitnexus/src/core/group/types.ts b/gitnexus/src/core/group/types.ts index db9d1989f..b0ba1a1dc 100644 --- a/gitnexus/src/core/group/types.ts +++ b/gitnexus/src/core/group/types.ts @@ -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; +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[]; @@ -89,6 +97,19 @@ export interface CrossLink { contractId: string; matchType: MatchType; confidence: number; + /** + * `true` when the PROVIDER endpoint (`to`) has no resolved graph symbol — + * empty `symbolUid` / `symbolRef` at sync time (e.g. the handler failed to + * resolve and `symbolName` degraded to the file name). The contract boundary + * is still proven, but the link cannot anchor a cross-impact fan-out: an + * empty provider uid never matches a Phase-1 symbol id, and a downstream + * fan-out into it has no neighbor symbol to resolve. Derived once at the + * sync persistence boundary (`isUnresolvedEndpoint` in normalization.ts) and + * re-derived by `dedupeCrossLinks` when a merge backfills the uid. Absent on + * fully-anchored links. Distinct from manifest `manifest::…` synthetic UIDs, + * which have their own `fanout_status: 'not_attempted'` channel downstream. + */ + degraded?: boolean; } export interface RepoSnapshot { @@ -100,7 +121,34 @@ export interface ContractRegistry { version: number; generatedAt: string; repoSnapshots: Record; + /** Configured repos with no entry in the registry. */ missingRepos: string[]; + /** + * Configured repos that ARE registered but that this sync could not extract + * from — the index would not open (version skew, lock, corruption), or an + * extractor threw partway through. The two are one bucket because the + * consequence is one thing: NONE of that repo's contracts are in this + * registry. Distinct from `missingRepos`, which is "no entry in the + * registry at all" and needs a different answer from the operator. + * + * Optional so a registry written before this field existed still parses — + * 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[]; } @@ -117,8 +165,36 @@ export interface RepoHandle { storagePath: string; } -/** Why local impact or fan-out stopped early (e.g. wall-clock budget exhausted). */ -export type GroupImpactTruncationReason = 'timeout' | 'partial'; +/** + * Why local impact or fan-out stopped early (e.g. wall-clock budget exhausted). + * + * `'timeout'` and `'partial'` are runtime limits — the same query can succeed on + * 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 + * list lets a guard test assert that. A test that hand-lists the members passes + * forever once a fourth is added — which is the exact drift the guard exists to + * 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', + 'suppressed-stage', +] as const; + +export type GroupImpactTruncationReason = (typeof GROUP_IMPACT_TRUNCATION_REASONS)[number]; export interface GroupImpactResult { local: unknown; @@ -133,7 +209,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. @@ -222,5 +308,118 @@ export interface BridgeHandle { export interface BridgeMeta { version: number; generatedAt: string; + /** + * Size and mtime of the `bridge.lbug` this metadata was written for, so a + * reader can tell whether the two still belong together. + * + * `writeBridge` replaces the database and writes this file as two operations; + * a sync that stops between them leaves the PREVIOUS sync's metadata beside a + * new database, and `runGroupImpact` reads completeness from that metadata. + * Stamping the pair is what lets `bridgeMetaMatchesFile` reject the mismatch + * without anything having to be deleted — deleting the old metadata up front + * would lose it permanently on a swap that fails with the old database still + * in place, which is a normal Windows outcome when a read-only handle is held. + * + * Optional: metadata written before this existed carries no stamp. Such a + * file is not waved through — `bridgeMetaMatchesFile` falls back to comparing + * the two files' modification times, since a successful write orders the + * database rename before the metadata write and a database NEWER than the + * metadata beside it therefore cannot be the one it describes. + * + * That fallback proves WRITE ORDER, not provenance, and is wrong in both + * directions — a non-monotonic clock can make a mis-paired set read as + * ordered, and any copy or restore that rewrites the database's times after + * the metadata's demotes an intact legacy pair to a lower bound until the + * next sync re-stamps it. A stamped pair never reaches that fallback, which + * is the reason to prefer stamping over widening the heuristic. Both + * directions are spelled out at `bridgeMetaMatchesFile`. + */ + bridgeSize?: number; + bridgeMtimeMs?: number; + /** + * Reader-side only: true when `meta.json` parsed but one of its repo lists + * held a value that was not a list of repo paths. + * + * NEVER PERSISTED. `readBridgeMeta` sets it to describe what it found in the + * file; `writeBridgeMeta`'s only caller builds a fresh literal, so it cannot + * round-trip back to disk. It lives on this interface rather than on a + * reader-only subtype so that `readBridgeMeta` keeps the exact signature + * every caller already compiles against. + * + * The unusable value is dropped rather than normalized, so `missingRepos: []` + * on such a result is inert filler — this flag, not the empty list, is what + * says the bridge's provenance is unknown. + */ + repoListsUnreadable?: boolean; + /** + * Reader-side only: did this metadata pair with the `bridge.lbug` beside it, + * measured BEFORE anything opened that database? + * + * NEVER PERSISTED, for the same reason as `repoListsUnreadable`. + * + * The measurement has to happen before the open, and the answer has to be + * carried rather than recomputed. `runGroupImpact` and `runGroupTrace` open + * the bridge and only then ask about provenance, so a platform where a + * read-only open advances the database's mtime would fail every unstamped + * pair the moment it was read — turning back-compat for pre-stamp bridges + * into a repo-wide "everything is a lower bound". Whether any given + * LadybugDB build and OS does that is not something a reader should have to + * know, and it cannot be observed on Windows, where the in-process + * write→read reopen this would need is a documented limitation. Ordering the + * check ahead of the open makes the question moot on every platform instead + * of true on the ones that happen to be testable. + */ + pairedWithDatabase?: boolean; + /** + * PERSISTED, unlike the two fields above: the writer of this metadata could + * not establish that it describes the `bridge.lbug` beside it, and no reader + * may conclude otherwise from the files alone. + * + * Written by `refreshPreservedBridgeMeta` — the preserve path in `syncGroup`, + * which refreshes the diagnostic lists of a bridge it deliberately does NOT + * rebuild. That refresh rewrites `meta.json` ATOMICALLY, so this file's mtime + * becomes now while the database's stays old; and "metadata newer than the + * database beside it" is exactly the write order that + * `unstampedMetaPairsByWriteOrder` accepts. A refresh that simply carried the + * old fields forward would therefore convert a pair that check had been + * REJECTING into one it waves through — laundering unknown provenance into + * verified provenance, which is the fail-open this whole channel exists to + * close. + * + * "Just don't write a stamp" is not a substitute, and is worse: an unstamped + * metadata file is judged on the two file times, and the refresh has already + * moved them into the accepting order. The verdict has to be recorded IN the + * file, because the write that records it is itself what destroys the + * evidence a reader would otherwise use. + * + * `bridgeMetaMatchesFile` rejects on this ahead of both the stamp and the + * write-order heuristic, so `ensureBridgeReady` answers + * `pairedWithDatabase: false` and `bridgeProvenanceUnknown` reports the + * cross-repo answer as a lower bound. That is the ONE enforcement point; do + * not add a second reader for this field. + * + * Self-clearing: a successful `writeBridge` builds fresh metadata from a + * literal and never sets it, so the next good sync retires the marker without + * anything having to delete it. + */ + provenanceUnknown?: boolean; missingRepos: string[]; + /** + * Configured repos the sync that produced this bridge could not extract from + * (see `ContractRegistry.unreadableRepos`). Their contracts and every + * cross-link touching them are absent from `bridge.lbug`, so a cross-repo + * impact query against this bridge is a lower bound, not a verdict — + * `runGroupImpact` folds a non-empty value into its truncation fields for + * exactly that reason. + * 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[]; } diff --git a/gitnexus/src/core/incremental/derived-writeback.ts b/gitnexus/src/core/incremental/derived-writeback.ts new file mode 100644 index 000000000..9cec191eb --- /dev/null +++ b/gitnexus/src/core/incremental/derived-writeback.ts @@ -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 = new Set(FTS_INDEXES.map((i) => i.table)); + +/** The FTS-backed members of `tables`. */ +export const ftsTablesAmong = (tables: Iterable): Set => { + const out = new Set(); + 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, +): 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, +): Set => { + const touched = new Set(); + 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[] => + allNodeTables.filter( + (tableName) => !FTS_TABLE_NAMES.has(tableName) || rebuildingFtsTables.has(tableName), + ); diff --git a/gitnexus/src/core/incremental/spring-config-drift.ts b/gitnexus/src/core/incremental/spring-config-drift.ts new file mode 100644 index 000000000..958f33924 --- /dev/null +++ b/gitnexus/src/core/incremental/spring-config-drift.ts @@ -0,0 +1,57 @@ +import type { KnowledgeGraph } from '../graph/types.js'; +import { SPRING_CONFIG_UNRESOLVED_PREFIX } from '../ingestion/frameworks/spring/config-bindings.js'; + +export interface PersistedSpringConfigConsumerRow { + readonly id?: unknown; + readonly description?: unknown; +} + +const CONSUMER_LABELS = new Set(['Property', 'Class', 'Record']); + +function unresolvedKeys(description: unknown): readonly string[] { + if (typeof description !== 'string') return []; + return description + .split(';') + .map((part) => part.trim()) + .filter((part) => part.startsWith(SPRING_CONFIG_UNRESOLVED_PREFIX)) + .map((part) => part.slice(SPRING_CONFIG_UNRESOLVED_PREFIX.length)) + .sort(); +} + +/** + * Find unchanged Spring consumer files whose unresolved markers changed. + * + * A removed config key also removes the old USES edge from the fresh graph, so + * ordinary new-graph boundary expansion cannot discover the consumer file. + */ +export function collectSpringConfigConsumerDriftFiles( + graph: KnowledgeGraph, + persistedRows: readonly PersistedSpringConfigConsumerRow[], +): Set { + const persistedById = new Map(); + for (const row of persistedRows) { + if (typeof row.id !== 'string') continue; + persistedById.set(row.id, unresolvedKeys(row.description)); + } + + const driftFiles = new Set(); + graph.forEachNode((node) => { + if (!CONSUMER_LABELS.has(node.label)) return; + const filePath = node.properties.filePath; + if (typeof filePath !== 'string') return; + const description = node.properties.description; + const persisted = persistedById.get(node.id); + if ( + persisted === undefined && + (typeof description !== 'string' || !description.includes(SPRING_CONFIG_UNRESOLVED_PREFIX)) + ) { + return; + } + const current = unresolvedKeys(description); + const prior = persisted ?? []; + if (current.length !== prior.length || current.some((key, index) => key !== prior[index])) { + driftFiles.add(filePath); + } + }); + return driftFiles; +} diff --git a/gitnexus/src/core/incremental/subgraph-extract.ts b/gitnexus/src/core/incremental/subgraph-extract.ts index e0f0e41eb..64751d99e 100644 --- a/gitnexus/src/core/incremental/subgraph-extract.ts +++ b/gitnexus/src/core/incremental/subgraph-extract.ts @@ -6,9 +6,10 @@ * 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 + * and `Destination` nodes are always included — their owning phase + * delete-alls them unconditionally before the writeback. * - 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 @@ -57,9 +58,31 @@ import { } from '../ingestion/frameworks/spring/auto-configuration.js'; import { isSpringAopEvidenceNode } from '../ingestion/frameworks/spring/aop.js'; +/** + * `Destination` is graph-wide for the same reason as the Spring AOP evidence + * nodes: the layer is recomputed in full on every run and deleted in full + * before the writeback (`deleteAllDestinations`), so it must be re-included in + * full or it is simply lost. + * + * The endpoint-writability rule cannot carry it. A RESOLVED destination stores + * no `filePath` at all — deliberately, so an incremental delete keyed on + * `filePath IN [...]` cannot cut a node shared across files — and the include + * test below starts from exactly that property. The result was a defect in both + * directions: a newly added file publishing to a new topic reported + * `added=1, exit 0` and silently put neither the destination nor the + * publisher's edge into the graph, so after the first index every new topic was + * invisible until a full rebuild; and a destination whose last referrer stopped + * referring to it survived forever as an edgeless orphan still carrying + * `address`, the cross-repository join key. + * + * Unresolved destinations DO carry a file path and would ride the ordinary + * rule, but they are included here too: the delete-all removes them as well, so + * anything not re-included would be dropped rather than merely stale. + */ const isGraphWideNode = (node: GraphNode): boolean => node.label === 'Community' || node.label === 'Process' || + node.label === 'Destination' || isSpringAopEvidenceNode(node) || isSpringAutoConfigurationSyntheticClass(node); @@ -122,13 +145,18 @@ const indexNodeFilePaths = (fullGraph: KnowledgeGraph): Map => { export const extractChangedSubgraph = ( fullGraph: KnowledgeGraph, toWriteSet: ReadonlySet, + options?: { includeDerivedGraphWide?: boolean }, ): KnowledgeGraph => { const sub = createKnowledgeGraph(); const writableNodeIds = new Set(); + 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); diff --git a/gitnexus/src/core/index-content-drift.ts b/gitnexus/src/core/index-content-drift.ts new file mode 100644 index 000000000..3599db7b1 --- /dev/null +++ b/gitnexus/src/core/index-content-drift.ts @@ -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; + +const HASH_BATCH = 100; + +const collectUnreadablePaths = async ( + repoPath: string, + relPaths: readonly string[], +): Promise => { + 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> | undefined, + coverage?: IndexCoveragePolicy, +): Promise => { + 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(); + 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' }; + } +}; diff --git a/gitnexus/src/core/index-freshness.ts b/gitnexus/src/core/index-freshness.ts index e34d371c2..52e62e8d8 100644 --- a/gitnexus/src/core/index-freshness.ts +++ b/gitnexus/src/core/index-freshness.ts @@ -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]; @@ -24,6 +27,32 @@ export const GRAPH_WRITE_COLLAPSE_RATIO = 0.5; */ export const GRAPH_WRITE_COLLAPSE_MIN_EDGES = 100; +/** Why {@link detectGraphWriteCollapse} could reach no verdict at all. */ +export type GraphWriteCollapseUnmeasurableReason = + /** The pipeline's own total was not a usable number (or was zero). */ + | 'expected-unavailable' + /** The DB-side count could not be READ — a query that threw, no connection. */ + | 'persisted-unreadable' + /** Set by the CALLER: an incremental write persists only the changed + * subgraph, so whole-scope counts are not comparable to it. */ + | 'incremental-write'; + +/** + * The three outcomes of the collapse check, kept APART because two of them used + * to share `undefined` and the conflation erased a stamp recording real, + * unrepaired edge loss. + * + * `'healthy'` is a POSITIVE all-clear — the counts were both taken and enough + * rows persisted — and is the only outcome that licenses clearing a previous + * `graph-write-collapsed` stamp. `'unmeasurable'` says the comparison never + * happened; the previous stamp must survive it, because nothing has repaired + * whatever it recorded. + */ +export type GraphWriteCollapseVerdict = + | { verdict: 'collapsed'; expected: number; persisted: number } + | { verdict: 'healthy' } + | { verdict: 'unmeasurable'; reason: GraphWriteCollapseUnmeasurableReason }; + /** * Decide whether a finished write collapsed, comparing what the pipeline * produced against what the DB hands back. @@ -34,7 +63,15 @@ export const GRAPH_WRITE_COLLAPSE_MIN_EDGES = 100; * * FAIL-SAFE at `expected === 0`: an implementation that offloads relationships * out of memory may not be able to report a total, and a false "your index is - * broken" is worse than a missed one. + * broken" is worse than a missed one. That case is `'unmeasurable'`, NOT + * `'healthy'` — nothing was compared, so nothing was cleared. + * + * Returns a THREE-WAY verdict rather than `{...} | undefined`. The absent value + * meant both "measured, fine" and "could not measure", and the caller — which + * decides whether to keep or erase the persisted `graph-write-collapsed` stamp — + * cannot tell those apart from a shared `undefined`. It guessed by write mode + * instead, so a full run whose structural count threw took the + * "no collapse ⇒ clear it" branch and deleted a stamp recording real loss. */ export function detectGraphWriteCollapse( expected: number, @@ -50,7 +87,7 @@ export function detectGraphWriteCollapse( * same confident-zero error it exists to catch. */ persisted: number | undefined, -): { expected: number; persisted: number } | undefined { +): GraphWriteCollapseVerdict { // Both sides must be REAL NUMBERS before any comparison. A non-numeric // `expected` (a graph implementation that reports no total, a lightweight // pipeline result) does not merely skip the guards — it INVERTS them: @@ -59,29 +96,51 @@ export function detectGraphWriteCollapse( // "passes" too and a healthy run is reported as a total collapse. Comparing // against a non-number is the one way this check can manufacture the exact // false certainty it was written to prevent. - if (!Number.isFinite(expected) || typeof persisted !== 'number' || !Number.isFinite(persisted)) { - return undefined; + if (!Number.isFinite(expected)) { + return { verdict: 'unmeasurable', reason: 'expected-unavailable' }; + } + if (typeof persisted !== 'number' || !Number.isFinite(persisted)) { + return { verdict: 'unmeasurable', reason: 'persisted-unreadable' }; } const expectedCount = expected; const persistedCount = persisted; + // FAIL-SAFE, and `'unmeasurable'` rather than `'healthy'`: a zero expectation + // is the documented "could not report a total" case, not evidence the write + // went well. Reporting it as an all-clear would let a run that measured + // nothing erase a stamp recording a previous run's real loss. + if (expectedCount === 0) { + return { verdict: 'unmeasurable', reason: 'expected-unavailable' }; + } // A TOTAL loss is never small enough to excuse. The min-edges exemption // exists for "a handful of edges lost to legitimate filtering", which its own // docstring says — it does not describe a persisted count of zero. Evaluated // before the exemption because the exemption looked only at `expected`: // `expected = 99, persisted = 0` lost every single edge and still returned - // `undefined`, leaving the metadata fresh and the CLI reporting success. + // no verdict, leaving the metadata fresh and the CLI reporting success. if (expectedCount > 0 && persistedCount === 0) { - return { expected: expectedCount, persisted: persistedCount }; + return { verdict: 'collapsed', expected: expectedCount, persisted: persistedCount }; } - if (expectedCount < GRAPH_WRITE_COLLAPSE_MIN_EDGES) return undefined; - if (persistedCount >= expectedCount * GRAPH_WRITE_COLLAPSE_RATIO) return undefined; - return { expected: expectedCount, persisted: persistedCount }; + // The small-repo exemption and the cleared ratio are both `'healthy'`, not + // `'unmeasurable'`: both counts WERE taken, and the comparison ran. Calling + // the exemption a non-verdict would make a stamp unclearable on any repo that + // shrank below the threshold — a permanent forced-rebuild wedge, which is the + // failure this taxonomy exists to avoid rather than to relocate. + if (expectedCount < GRAPH_WRITE_COLLAPSE_MIN_EDGES) return { verdict: 'healthy' }; + if (persistedCount >= expectedCount * GRAPH_WRITE_COLLAPSE_RATIO) return { verdict: 'healthy' }; + return { verdict: 'collapsed', expected: expectedCount, persisted: persistedCount }; } /** Stable machine-readable reasons an index cannot be certified complete. */ export function getIndexIncompleteReasons( meta: - | Pick + | Pick< + RepoMeta, + | 'incrementalInProgress' + | 'embeddingCheckpoint' + | 'graphWriteCollapsed' + | 'scopeExtractionFailures' + | 'scopeExtractionReceipt' + > | null | undefined, ): IndexIncompleteReason[] { @@ -93,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) diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index ffce0a538..610280c4f 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -18,7 +18,7 @@ import { KnowledgeGraph } from '../graph/types.js'; import type { SemanticModel, SymbolTableReader } from './model/index.js'; import { generateId } from '../../lib/utils.js'; -import type { SymbolDefinition } from 'gitnexus-shared'; +import type { ParsedImport, SymbolDefinition } from 'gitnexus-shared'; import { yieldToEventLoop } from './utils/event-loop.js'; import type { ExtractedRoute, ExtractedFetchCall } from './workers/parse-worker.js'; import type { ExtractedDecoratorRoute } from './workers/parse-worker.js'; @@ -29,6 +29,7 @@ import { routeNodeKey, } from './route-extractors/route-path.js'; import { extractReturnTypeName } from './type-extractors/shared.js'; +import { DATA_ROUTE_TABLE_SOURCE } from './route-extractors/data-route-table.js'; const MAX_EXPORTS_PER_FILE = 500; const MAX_TYPE_NAME_LENGTH = 256; @@ -37,6 +38,18 @@ const MAX_TYPE_NAME_LENGTH = 256; * Consumed by the cross-file re-resolution / enrichment pass. */ export type ExportedTypeMap = Map>; +interface RouteResolutionFile { + readonly filePath: string; + readonly parsedImports: readonly ParsedImport[]; + readonly localDefs: readonly SymbolDefinition[]; +} + +interface RouteHandlerResolutionContext { + readonly files: readonly RouteResolutionFile[]; + readonly resolveImportTarget: (parsedImport: ParsedImport, fromFile: string) => string | null; + readonly isExportedSymbol: (nodeId: string) => boolean; +} + /** Record one exported graph node into the incremental ExportedTypeMap. */ export const accumulateExportedTypesFromParsedNode = ( result: ExportedTypeMap, @@ -281,6 +294,7 @@ export function resolveRouteHandlerSymbols( model: SemanticModel, extractedRoutes: readonly ExtractedRoute[], decoratorRoutes: readonly ExtractedDecoratorRoute[], + routeContext?: RouteHandlerResolutionContext, ): Map { const out = new Map(); // Route identities already claimed by an earlier route (resolved or not). @@ -295,13 +309,106 @@ export function resolveRouteHandlerSymbols( return defs.length === 1 ? defs[0]?.nodeId : undefined; }; + const uniqueById = (defs: readonly SymbolDefinition[]): SymbolDefinition | undefined => { + const byId = new Map(defs.map((def) => [def.nodeId, def])); + return byId.size === 1 ? byId.values().next().value : undefined; + }; + + const routeCallables = (defs: readonly SymbolDefinition[]): readonly SymbolDefinition[] => + defs.filter((def) => def.type === 'Function' || def.type === 'Method'); + + const exportedRouteCallables = ( + defs: readonly SymbolDefinition[], + ): readonly SymbolDefinition[] => + routeContext === undefined + ? [] + : routeCallables(defs).filter((def) => routeContext.isExportedSymbol(def.nodeId)); + + const filesByPath = new Map(routeContext?.files.map((file) => [file.filePath, file]) ?? []); + + const uniqueImport = (filePath: string, localName: string): ParsedImport | undefined => { + const matches = (filesByPath.get(filePath)?.parsedImports ?? []).filter( + (parsedImport) => + 'localName' in parsedImport && + parsedImport.localName === localName && + parsedImport.kind !== 'dynamic-unresolved', + ); + return matches.length === 1 ? matches[0] : undefined; + }; + + const importedTarget = ( + filePath: string, + localName: string, + ): { parsedImport: ParsedImport; targetFile: string } | undefined => { + if (routeContext === undefined) return undefined; + const parsedImport = uniqueImport(filePath, localName); + if (parsedImport === undefined) return undefined; + const targetFile = routeContext.resolveImportTarget(parsedImport, filePath); + return targetFile === null ? undefined : { parsedImport, targetFile }; + }; + + const resolveDataRouteHandler = (filePath: string, designator: string): string | undefined => { + const parts = designator.split('.'); + if (parts.length === 1) { + const local = uniqueById(routeCallables(model.symbols.lookupExactAll(filePath, designator))); + if (local !== undefined) return local.nodeId; + + const imported = importedTarget(filePath, designator); + if ( + imported === undefined || + imported.parsedImport.kind === 'namespace' || + imported.parsedImport.kind === 'wildcard' || + !('importedName' in imported.parsedImport) + ) { + return undefined; + } + if (imported.parsedImport.importedName === 'default') { + // ParsedFile does not carry explicit default-export provenance. Fail + // closed rather than infer an unrelated named export from the module. + return undefined; + } + return uniqueById( + exportedRouteCallables( + model.symbols.lookupExactAll(imported.targetFile, imported.parsedImport.importedName), + ), + )?.nodeId; + } + if (parts.length !== 2) return undefined; + + const [receiver, member] = parts; + const localOwner = uniqueById(model.symbols.lookupExactAll(filePath, receiver)); + if (localOwner !== undefined) { + return uniqueById(model.methods.lookupAllByOwner(localOwner.nodeId, member))?.nodeId; + } + + const imported = importedTarget(filePath, receiver); + if (imported === undefined || imported.parsedImport.kind === 'wildcard') return undefined; + if (imported.parsedImport.kind === 'namespace') { + return uniqueById( + exportedRouteCallables(model.symbols.lookupExactAll(imported.targetFile, member)), + )?.nodeId; + } + if (!('importedName' in imported.parsedImport)) return undefined; + if (imported.parsedImport.importedName === 'default') return undefined; + const owner = uniqueById( + model.symbols + .lookupExactAll(imported.targetFile, imported.parsedImport.importedName) + .filter((def) => routeContext?.isExportedSymbol(def.nodeId) === true), + ); + return owner === undefined + ? undefined + : uniqueById(model.methods.lookupAllByOwner(owner.nodeId, member))?.nodeId; + }; + const claim = ( routePath: string | null, prefix: string | null, httpMethod: string | null | undefined, symbolId: string | undefined, ) => { - if (!routePath) return; + // An empty path is a valid, pathless mapping and normalizes to either `/` + // or its class/router prefix. Only null means the extractor had no route. + if (routePath === null) return; const url = normalizeExtractedRoutePath(routePath, prefix); const key = routeNodeKey(normalizeRouteMethod(httpMethod), url); if (claimed.has(key)) return; // first-writer-wins: later same-key routes can't override @@ -326,10 +433,49 @@ export function resolveRouteHandlerSymbols( claim(route.routePath, route.prefix ?? null, route.httpMethod, methodId); } - // Decorator routes (Spring / FastAPI / generic) — the decorated handler in - // the route's own file. + const dataHandlerByRoute = new Map(); + const dataHandlersByIdentity = new Map< + string, + { handlers: Set; hasUnresolved: boolean } + >(); for (const dr of decoratorRoutes) { - const handlerId = dr.handlerName ? uniqueSymbolId(dr.filePath, dr.handlerName) : undefined; + if (dr.source !== DATA_ROUTE_TABLE_SOURCE || !dr.handlerName || !dr.routePath) continue; + const handlerId = resolveDataRouteHandler(dr.filePath, dr.handlerName); + const url = normalizeExtractedRoutePath(dr.routePath, dr.prefix ?? null); + const key = routeNodeKey(normalizeRouteMethod(dr.httpMethod), url); + const state = dataHandlersByIdentity.get(key) ?? { + handlers: new Set(), + hasUnresolved: false, + }; + if (handlerId === undefined) { + state.hasUnresolved = true; + } else { + dataHandlerByRoute.set(dr, handlerId); + state.handlers.add(handlerId); + } + dataHandlersByIdentity.set(key, state); + } + + // Decorator routes (Spring / FastAPI / generic) — the decorated handler in + // the route's own file. Data tables additionally suppress an identity when + // duplicate entries resolve to different handlers: recording either one + // would invent a single-winner dispatch that the loop does not prove. + for (const dr of decoratorRoutes) { + const handlerId = + dr.source === DATA_ROUTE_TABLE_SOURCE + ? dataHandlerByRoute.get(dr) + : dr.handlerName + ? uniqueSymbolId(dr.filePath, dr.handlerName) + : undefined; + // An unproven data-table entry never becomes a Route node, so it must not + // reserve the identity and suppress a later, valid framework declaration. + if (dr.source === DATA_ROUTE_TABLE_SOURCE && handlerId === undefined) continue; + if (dr.source === DATA_ROUTE_TABLE_SOURCE && dr.routePath) { + const url = normalizeExtractedRoutePath(dr.routePath, dr.prefix ?? null); + const key = routeNodeKey(normalizeRouteMethod(dr.httpMethod), url); + const state = dataHandlersByIdentity.get(key); + if (state === undefined || state.hasUnresolved || state.handlers.size !== 1) continue; + } claim(dr.routePath, dr.prefix ?? null, dr.httpMethod, handlerId); } diff --git a/gitnexus/src/core/ingestion/cluster-enricher.ts b/gitnexus/src/core/ingestion/cluster-enricher.ts index 06cd4d0cd..32bfe8b3a 100644 --- a/gitnexus/src/core/ingestion/cluster-enricher.ts +++ b/gitnexus/src/core/ingestion/cluster-enricher.ts @@ -7,6 +7,7 @@ import { CommunityNode } from './community-processor.js'; +import { chunk } from '../../lib/utils.js'; import { logger } from '../logger.js'; // ============================================================================ // TYPES @@ -160,11 +161,13 @@ export const enrichClustersBatch = async ( let tokensUsed = 0; // Process in batches - for (let i = 0; i < communities.length; i += batchSize) { - // Report progress - onProgress?.(Math.min(i + batchSize, communities.length), communities.length); - - const batch = communities.slice(i, i + batchSize); + let reported = 0; + for (const batch of chunk(communities, batchSize)) { + // Report progress. `reported` after each whole batch equals the old + // `Math.min(i + batchSize, communities.length)` — the last batch is short + // exactly when that clamp used to bite. + reported += batch.length; + onProgress?.(reported, communities.length); const batchPrompt = batch .map((community, idx) => { diff --git a/gitnexus/src/core/ingestion/destination-key.ts b/gitnexus/src/core/ingestion/destination-key.ts new file mode 100644 index 000000000..31a76c307 --- /dev/null +++ b/gitnexus/src/core/ingestion/destination-key.ts @@ -0,0 +1,62 @@ +/** + * Shared destination-identity keying — the async counterpart of `routeNodeKey` + * in `route-extractors/route-path.ts`. + * + * Deliberately OUTSIDE `frameworks/spring/`, and for the same reason + * `routeNodeKey` sits outside the routes phase: the identity has to be mintable + * by anything that names a broker address, so a Node Kafka client or a Celery + * task queue can land on the very node a Spring publisher minted. A key that + * lived in the Spring module would force every other producer to import Spring, + * or — worse — let each one invent its own spelling, and two spellings of one + * address is precisely the missed connection this overlay exists to make. + * + * `broker` is a plain `string`, NOT the Spring `SpringDestinationBroker` union. + * Importing that union here is the dependency this module exists to avoid, and + * widening it costs nothing that matters: the union is a subtype of `string`, + * so a Spring caller passes its own values unchanged, while a future + * non-Spring caller stays free to attest to a broker Spring has no name for. + * The trade is real but small — this signature cannot reject a misspelled + * broker — and it is the same trade `routeNodeKey` makes by taking `method` as + * a `string` rather than an HTTP-verb union. Pure string logic, no + * dependencies. + */ + +/** + * The `Destination` node identity: `(broker, address)` when the broker is + * known, falling back to the address alone when it is not. + * + * The broker belongs IN the key, exactly as the HTTP verb belongs in + * `routeNodeKey`. `GET /x` and `POST /x` are two nodes, both fully joinable, + * and neither is punished for the other's existence; `kafka orders` and + * `rabbit orders` are two nodes on the same terms. A Kafka topic and a Rabbit + * queue that happen to share a name are two places, and one node for both would + * report a publisher and a subscriber as connected when nothing connects them. + * + * The known objection is that the broker is INFERRED — from a receiver's name, + * from an annotation table — so a wrong guess splits a pair that is really one. + * That is true and it is the cost. It is worth paying because the alternative + * tried first was worse: withdrawing the address from every site that named it + * split the pair even when the guess was RIGHT, since one unrelated third party + * writing the same word anywhere in the repository was enough to disconnect + * everybody on that spelling. Putting the broker in the key bounds the damage + * of a wrong guess to the one pair it was wrong about, instead of spreading it + * to every pair that shares an address with a stranger. + * + * ── THE ADDRESS-ONLY FALLBACK IS UNREACHABLE TODAY ────────────────────── + * + * `SpringDestinationCandidate.broker` is REQUIRED, and every annotation rule + * and every producer template supplies one, so no Spring caller can reach the + * `undefined` branch. It is written anyway, and on purpose: the parameter shape + * is the contract this module offers the next language, and the next language + * may well capture an address without being able to attest to a broker (a bare + * `queue.publish(name)` in a dynamic language, a binding that names only a + * channel). Degrading to address-only is the right answer there — silence about + * the broker is not a claim about it, and refusing to key such a site at all + * would lose a real destination over a value nobody disagreed about. + * + * Because the branch is dead, it is covered by testing THIS function directly + * rather than by a pipeline test staged to look as though a phase reached it. + */ +export function destinationNodeKey(broker: string | undefined, address: string): string { + return broker ? `${broker} ${address}` : address; +} diff --git a/gitnexus/src/core/ingestion/di-extractors/index.ts b/gitnexus/src/core/ingestion/di-extractors/index.ts index 5b679feb1..66a42775d 100644 --- a/gitnexus/src/core/ingestion/di-extractors/index.ts +++ b/gitnexus/src/core/ingestion/di-extractors/index.ts @@ -15,56 +15,17 @@ */ import { SupportedLanguages } from 'gitnexus-shared'; -import type { GraphNode } from 'gitnexus-shared'; +import type { DiResolver } from './types.js'; import { springDiResolver } from './spring.js'; -/** A successful injection-site match, produced by a per-language resolver. */ -export interface DiInjectionMatch { - /** The requested dependency type name. */ - targetTypeName: string; - /** A collection receives every matching provider; a single site may need - * framework-specific named/preferred-provider disambiguation. */ - cardinality: 'single' | 'collection'; - /** Statically known provider name requested at the injection site. The - * resolver owns the human-readable explanation of that selection. */ - namedSelection?: { - name: string; - reason: string; - /** Name-first frameworks may fall back to type only for implicit/default - * names. Explicit names remain strict. */ - fallbackToType?: boolean; - }; - /** Most injection edges originate at the owning Class. Factory-method - * parameters preserve the Method as the semantic source. */ - edgeSource?: 'owner-class' | 'site'; - /** Human-readable edge reason. Framework specifics (names, idioms, - * collection wrapper, gating annotation) live in this payload so the - * shared `di` phase stays framework-neutral. */ - reason: string; -} - -/** Provider metadata used by the shared resolver without naming a framework. */ -export interface DiProviderMatch { - /** Provider names and aliases that can satisfy a named injection. */ - names: readonly string[]; - /** Optional type directly provided by a declaration node, such as a - * framework factory method whose node is not itself a Class. */ - providedTypeName?: string; - /** Graph node that declares this provider. The shared phase excludes a - * provider from injection into its own declaration site without knowing the - * framework-specific declaration model. */ - declaredByNodeId?: string; - /** Present when the framework marks this as its preferred candidate. The - * value is appended to the emitted edge reason when it disambiguates. */ - preferenceReason?: string; -} - -/** Per-language DI behavior. Matchers receive whole nodes so the shared phase - * remains ignorant of language/framework-specific property shapes. */ -export interface DiResolver { - matchInjectionSites(node: GraphNode): readonly DiInjectionMatch[]; - matchProvider(node: GraphNode): DiProviderMatch | null; -} +/** The resolver contract lives in the leaf `./types.js` so an implementation + * can depend on it without depending on this registry (which imports every + * implementation). The two match shapes are re-exported here because consumers + * of the registry read them off its results — `pipeline-phases/di.ts` and the + * Spring metadata modules import them from this module alongside + * `DI_RESOLVERS`. `DiResolver` itself is NOT re-exported: only implementations + * need it, and they import it from `./types.js` directly. */ +export type { DiInjectionMatch, DiProviderMatch } from './types.js'; /** All `SupportedLanguages` string values, for narrowing raw graph strings. */ const SUPPORTED_LANGUAGE_VALUES: ReadonlySet = new Set(Object.values(SupportedLanguages)); diff --git a/gitnexus/src/core/ingestion/di-extractors/spring.ts b/gitnexus/src/core/ingestion/di-extractors/spring.ts index ea5c8e727..1b0b35f6e 100644 --- a/gitnexus/src/core/ingestion/di-extractors/spring.ts +++ b/gitnexus/src/core/ingestion/di-extractors/spring.ts @@ -59,7 +59,7 @@ */ import type { GraphNode } from 'gitnexus-shared'; -import type { DiInjectionMatch, DiProviderMatch, DiResolver } from './index.js'; +import type { DiInjectionMatch, DiProviderMatch, DiResolver } from './types.js'; import { isDev } from '../utils/env.js'; import { logger } from '../../logger.js'; diff --git a/gitnexus/src/core/ingestion/di-extractors/types.ts b/gitnexus/src/core/ingestion/di-extractors/types.ts new file mode 100644 index 000000000..5a0621263 --- /dev/null +++ b/gitnexus/src/core/ingestion/di-extractors/types.ts @@ -0,0 +1,64 @@ +/** + * The DI resolver contract — the types a per-language/per-framework resolver + * implements and the shared `di` pipeline phase consumes. + * + * A leaf module by design: it imports nothing from this directory, so the + * barrel (`./index.ts`, which aggregates the resolver *implementations*) and + * each implementation (`./spring.ts`) can both depend on the contract without + * depending on each other. The barrel re-exports the two MATCH types, because + * consumers of the registry read them off its results; `DiResolver` is not + * re-exported, since only implementations need it and they import it from here + * directly. + * + * Mirrors the `import-resolvers/types.ts` split of contract from registry. + */ + +import type { GraphNode } from 'gitnexus-shared'; + +/** A successful injection-site match, produced by a per-language resolver. */ +export interface DiInjectionMatch { + /** The requested dependency type name. */ + targetTypeName: string; + /** A collection receives every matching provider; a single site may need + * framework-specific named/preferred-provider disambiguation. */ + cardinality: 'single' | 'collection'; + /** Statically known provider name requested at the injection site. The + * resolver owns the human-readable explanation of that selection. */ + namedSelection?: { + name: string; + reason: string; + /** Name-first frameworks may fall back to type only for implicit/default + * names. Explicit names remain strict. */ + fallbackToType?: boolean; + }; + /** Most injection edges originate at the owning Class. Factory-method + * parameters preserve the Method as the semantic source. */ + edgeSource?: 'owner-class' | 'site'; + /** Human-readable edge reason. Framework specifics (names, idioms, + * collection wrapper, gating annotation) live in this payload so the + * shared `di` phase stays framework-neutral. */ + reason: string; +} + +/** Provider metadata used by the shared resolver without naming a framework. */ +export interface DiProviderMatch { + /** Provider names and aliases that can satisfy a named injection. */ + names: readonly string[]; + /** Optional type directly provided by a declaration node, such as a + * framework factory method whose node is not itself a Class. */ + providedTypeName?: string; + /** Graph node that declares this provider. The shared phase excludes a + * provider from injection into its own declaration site without knowing the + * framework-specific declaration model. */ + declaredByNodeId?: string; + /** Present when the framework marks this as its preferred candidate. The + * value is appended to the emitted edge reason when it disambiguates. */ + preferenceReason?: string; +} + +/** Per-language DI behavior. Matchers receive whole nodes so the shared phase + * remains ignorant of language/framework-specific property shapes. */ +export interface DiResolver { + matchInjectionSites(node: GraphNode): readonly DiInjectionMatch[]; + matchProvider(node: GraphNode): DiProviderMatch | null; +} diff --git a/gitnexus/src/core/ingestion/entry-point-scoring.ts b/gitnexus/src/core/ingestion/entry-point-scoring.ts index 58cf9389c..30a9c78c1 100644 --- a/gitnexus/src/core/ingestion/entry-point-scoring.ts +++ b/gitnexus/src/core/ingestion/entry-point-scoring.ts @@ -13,6 +13,7 @@ import { detectFrameworkFromPath } from './framework-detection.js'; import { SupportedLanguages } from 'gitnexus-shared'; import { providers } from './languages/index.js'; +import { isTestFilePath } from './utils/test-file-path.js'; // ============================================================================ // NAME PATTERNS @@ -164,54 +165,15 @@ export function calculateEntryPointScore( // ============================================================================ /** - * Check if a file path is a test file (should be excluded from entry points) - * Covers common test file patterns across all supported languages + * Check if a file path is a test file (should be excluded from entry points). + * + * Delegates to the shared predicate in `utils/test-file-path.ts`. This used to be + * a second, hand-maintained copy that had drifted from the one backing the MCP + * `includeTests` flag — see that module's header. Re-exported under this name so + * existing importers are unaffected. */ export function isTestFile(filePath: string): boolean { - const p = filePath.toLowerCase().replace(/\\/g, '/'); - - return ( - // JavaScript/TypeScript test patterns - p.includes('.test.') || - p.includes('.spec.') || - p.includes('__tests__/') || - p.includes('__mocks__/') || - // Generic test folders - p.includes('/test/') || - p.includes('/tests/') || - p.includes('/testing/') || - // Python test patterns - p.endsWith('_test.py') || - p.includes('/test_') || - // Go test patterns - p.endsWith('_test.go') || - // Java test patterns - p.includes('/src/test/') || - // Rust test patterns (inline tests are different, but test files) - p.includes('/tests/') || - // Swift/iOS test patterns - p.endsWith('tests.swift') || - p.endsWith('test.swift') || - p.includes('uitests/') || - // C# test patterns - p.endsWith('tests.cs') || - p.endsWith('test.cs') || - p.includes('.tests/') || - p.includes('.test/') || - p.includes('.integrationtests/') || - p.includes('.unittests/') || - p.includes('/testproject/') || - // PHP/Laravel test patterns - p.endsWith('test.php') || - p.endsWith('spec.php') || - p.includes('/tests/feature/') || - p.includes('/tests/unit/') || - // Ruby test patterns - p.endsWith('_spec.rb') || - p.endsWith('_test.rb') || - p.includes('/spec/') || - p.includes('/test/fixtures/') - ); + return isTestFilePath(filePath); } /** diff --git a/gitnexus/src/core/ingestion/filesystem-walker.ts b/gitnexus/src/core/ingestion/filesystem-walker.ts index 823b3670f..7e41c07c2 100644 --- a/gitnexus/src/core/ingestion/filesystem-walker.ts +++ b/gitnexus/src/core/ingestion/filesystem-walker.ts @@ -4,6 +4,7 @@ import fs from 'fs/promises'; import path from 'path'; import { glob } from 'glob'; import { createIgnoreFilter } from '../../config/ignore-service.js'; +import { mapConcurrent } from '../../lib/utils.js'; import { logger } from '../logger.js'; @@ -21,6 +22,27 @@ export interface FilePath { const READ_CONCURRENCY = 32; const ANALYZE_PROGRESS_ACTIVE_ENV = 'GITNEXUS_ANALYZE_PROGRESS_ACTIVE'; +const DECLARATION_COMPANION_SUFFIXES = [ + { declaration: '.d.ts', implementations: ['.ts', '.tsx'] }, + { declaration: '.d.mts', implementations: ['.mts'] }, + { declaration: '.d.cts', implementations: ['.cts'] }, +] as const; + +const hasImplementationSibling = ( + declarationPath: string, + scannedPaths: ReadonlySet, +): boolean => { + const companion = DECLARATION_COMPANION_SUFFIXES.find(({ declaration }) => + declarationPath.endsWith(declaration), + ); + if (!companion) return false; + + // Keep standalone declarations. Only suppress declaration output that sits + // beside an implementation with the corresponding module suffix. + const stem = declarationPath.slice(0, -companion.declaration.length); + return companion.implementations.some((suffix) => scannedPaths.has(`${stem}${suffix}`)); +}; + const warnLargeFileSkip = (message: string): void => { if (process.env[ANALYZE_PROGRESS_ACTIVE_ENV] === '1') { // analyze.ts routes console.warn through the progress bar logger while @@ -35,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 => { + 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 => { + await assertWalkRootIsDirectory(repoPath); const ignoreFilter = await createIgnoreFilter(repoPath); - const maxFileSizeBytes = getMaxFileSizeBytes(); + const maxFileSizeBytes = options.maxFileSizeBytes ?? getMaxFileSizeBytes(); const filtered = await glob('**/*', { cwd: repoPath, @@ -83,12 +138,19 @@ export const walkRepositoryPaths = async ( } } + const scannedPaths = new Set(entries.map((entry) => entry.path)); + const deduplicatedEntries = entries.filter( + (entry) => !hasImplementationSibling(entry.path, scannedPaths), + ); + // Filesystem/glob traversal order is not stable across filesystems or repeated // scans. Canonicalize once at the scan boundary so every downstream phase sees // the same repository order. - entries.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0)); + deduplicatedEntries.sort((left, right) => + 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' : ''; @@ -122,7 +184,7 @@ export const walkRepositoryPaths = async ( } } - return entries; + return deduplicatedEntries; }; /** @@ -135,21 +197,21 @@ export const readFileContents = async ( ): Promise> => { const contents = new Map(); - for (let start = 0; start < relativePaths.length; start += READ_CONCURRENCY) { - const batch = relativePaths.slice(start, start + READ_CONCURRENCY); - const results = await Promise.allSettled( - batch.map(async (relativePath) => { - const fullPath = path.join(repoPath, relativePath); - const content = await fs.readFile(fullPath, 'utf-8'); - return { path: relativePath, content }; - }), - ); + const results = await mapConcurrent( + relativePaths, + async (relativePath) => { + const fullPath = path.join(repoPath, relativePath); + const content = await fs.readFile(fullPath, 'utf-8'); + return { path: relativePath, content }; + }, + { concurrency: READ_CONCURRENCY }, + ); - for (const result of results) { - if (result.status === 'fulfilled') { - contents.set(result.value.path, result.value.content); - } - } + // An unreadable file yields `undefined` (mapConcurrent's per-item degrade) and + // is skipped, exactly as the previous allSettled/`status === 'fulfilled'` shape + // did — no `onError`, so the skip stays silent per this function's contract. + for (const result of results) { + if (result) contents.set(result.path, result.content); } return contents; diff --git a/gitnexus/src/core/ingestion/frameworks/spring/actuator-runtime.ts b/gitnexus/src/core/ingestion/frameworks/spring/actuator-runtime.ts new file mode 100644 index 000000000..4af7073cb --- /dev/null +++ b/gitnexus/src/core/ingestion/frameworks/spring/actuator-runtime.ts @@ -0,0 +1,968 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import type { GraphNode } from 'gitnexus-shared'; +import { generateId } from '../../../../lib/utils.js'; +import type { KnowledgeGraph } from '../../../graph/types.js'; +import { SPRING_DI_PROVIDER_PROPERTY } from '../../di-extractors/spring.js'; +import { + normalizeExtractedRoutePath, + normalizeRouteMethod, + routeNodeKey, +} from '../../route-extractors/route-path.js'; +import { stripBidiAndZeroWidth } from '../../utils/ast-helpers.js'; +import { SPRING_CONFIG_DESCRIPTION } from './config-bindings.js'; +import { getProviderForFile } from '../../languages/index.js'; +import type { RuntimeCallableIdentity } from '../../language-provider.js'; + +export const ACTUATOR_ENDPOINTS = [ + 'mappings', + 'beans', + 'conditions', + 'configprops', + 'env', +] as const; +type ActuatorEndpoint = (typeof ACTUATOR_ENDPOINTS)[number]; + +const MAX_ACTUATOR_PAYLOAD_BYTES = 16 * 1024 * 1024; +export const MAX_RUNTIME_RECORDS = 50_000; +const MAX_RUNTIME_DEPTH = 64; +const RUNTIME_FILE_PREFIX = 'spring-actuator:'; + +type JsonObject = Record; + +export interface SpringActuatorImportStats { + readonly payloads: number; + readonly mappings: number; + readonly beans: number; + readonly conditions: number; + readonly configProperties: number; + readonly environmentProperties: number; + /** Endpoint categories that exceeded the bounded import size. */ + readonly truncatedEndpoints: readonly ActuatorEndpoint[]; +} + +interface MutableImportStats { + payloads: number; + mappings: number; + beans: number; + conditions: number; + configProperties: number; + environmentProperties: number; + truncatedEndpoints: ActuatorEndpoint[]; +} + +interface ImportResult { + readonly count: number; + readonly truncated: boolean; +} + +export class SpringActuatorImportError extends Error { + constructor(message: string) { + super(message); + this.name = 'SpringActuatorImportError'; + } +} + +function objectValue(value: unknown): JsonObject | undefined { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as JsonObject) + : undefined; +} + +function safeText(value: unknown, maxLength = 1024): string | undefined { + if (typeof value !== 'string') return undefined; + const sanitized = stripBidiAndZeroWidth(value) + .replace(/[\u0000-\u001f\u007f]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + return sanitized.length === 0 ? undefined : sanitized.slice(0, maxLength); +} + +function safeStrings(value: unknown, limit = 100): string[] { + if (!Array.isArray(value)) return []; + const strings: string[] = []; + for (const item of value.slice(0, limit)) { + const text = safeText(item); + if (text !== undefined) strings.push(text); + } + return strings; +} + +async function readPayloadFile(filePath: string, label: string): Promise { + // Size gate and read share one handle so both observe the same inode. + // Re-resolving the path for the read would let a swapped file bypass the + // payload cap (CodeQL js/file-system-race). + let handle: Awaited> | undefined; + let raw: string; + try { + handle = await fs.open(filePath, 'r'); + const stat = await handle.stat(); + if (!stat.isFile()) { + throw new SpringActuatorImportError(`Spring Actuator ${label} input must be a JSON file.`); + } + if (stat.size > MAX_ACTUATOR_PAYLOAD_BYTES) { + throw new SpringActuatorImportError( + `Spring Actuator ${label} payload exceeds the ${MAX_ACTUATOR_PAYLOAD_BYTES / 1024 / 1024} MiB limit.`, + ); + } + const buffer = Buffer.alloc(MAX_ACTUATOR_PAYLOAD_BYTES + 1); + let bytesRead = 0; + while (bytesRead < buffer.length) { + const result = await handle.read(buffer, bytesRead, buffer.length - bytesRead, bytesRead); + if (result.bytesRead === 0) break; + bytesRead += result.bytesRead; + } + if (bytesRead > MAX_ACTUATOR_PAYLOAD_BYTES) { + throw new SpringActuatorImportError( + `Spring Actuator ${label} payload exceeds the ${MAX_ACTUATOR_PAYLOAD_BYTES / 1024 / 1024} MiB limit.`, + ); + } + raw = buffer.subarray(0, bytesRead).toString('utf8'); + } catch (err) { + if (err instanceof SpringActuatorImportError) throw err; + throw new SpringActuatorImportError(`Spring Actuator ${label} input could not be read.`); + } finally { + await handle?.close().catch(() => {}); + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + // Do not include JSON.parse's message: newer runtimes may quote source text, + // which could disclose an env/configprops value in CLI output. + throw new SpringActuatorImportError(`Spring Actuator ${label} payload is not valid JSON.`); + } + const object = objectValue(parsed); + if (object === undefined) { + throw new SpringActuatorImportError(`Spring Actuator ${label} payload must be a JSON object.`); + } + return object; +} + +async function loadPayloads( + repoPath: string, + configuredPath: string, +): Promise> { + const inputPath = path.resolve(repoPath, configuredPath); + let stat; + try { + stat = await fs.stat(inputPath); + } catch { + throw new SpringActuatorImportError( + 'Spring Actuator input path does not exist or is unreadable.', + ); + } + + const payloads = new Map(); + if (stat.isDirectory()) { + for (const endpoint of ACTUATOR_ENDPOINTS) { + const filePath = path.join(inputPath, `${endpoint}.json`); + try { + const endpointStat = await fs.stat(filePath); + if (!endpointStat.isFile()) continue; + } catch { + continue; + } + payloads.set(endpoint, await readPayloadFile(filePath, endpoint)); + } + } else if (stat.isFile()) { + const parsed = await readPayloadFile(inputPath, 'bundle'); + const endpointFromName = ACTUATOR_ENDPOINTS.find( + (endpoint) => path.basename(inputPath).toLowerCase() === `${endpoint}.json`, + ); + if (endpointFromName !== undefined) { + payloads.set(endpointFromName, parsed); + } else { + for (const endpoint of ACTUATOR_ENDPOINTS) { + const payload = objectValue(parsed[endpoint]); + if (payload !== undefined) payloads.set(endpoint, payload); + } + } + } else { + throw new SpringActuatorImportError( + 'Spring Actuator input must be a JSON bundle or a directory of endpoint JSON files.', + ); + } + + if (payloads.size === 0) { + throw new SpringActuatorImportError( + 'Spring Actuator input contains none of mappings, beans, conditions, configprops, or env.', + ); + } + return payloads; +} + +function evidenceFile(graph: KnowledgeGraph, endpoint: ActuatorEndpoint): GraphNode { + const filePath = `${RUNTIME_FILE_PREFIX}${endpoint}`; + const id = generateId('File', filePath); + const existing = graph.getNode(id); + if (existing !== undefined) return existing; + const node: GraphNode = { + id, + label: 'File', + properties: { name: `${endpoint}.json`, filePath }, + }; + graph.addNode(node); + return node; +} + +function appendRuntimeMarker(node: GraphNode, marker: string): void { + const current = + typeof node.properties.description === 'string' ? node.properties.description : ''; + if (current.includes(marker)) return; + node.properties.description = current.length === 0 ? marker : `${current}; ${marker}`; +} + +function markRuntimeEvidence( + graph: KnowledgeGraph, + endpoint: ActuatorEndpoint, + target: GraphNode, + status: string = 'runtime-confirmed', + confirmed: boolean = true, +): void { + // Only Route declares structured runtime columns in the persisted schema. + // Other labels retain the same evidence durably through their description + // plus the DECLARES edge below; setting undeclared properties would make the + // in-memory graph promise data that CSV/LadybugDB silently drops. + if (target.label === 'Route') { + // Confirmation is conflict-dominant. Once any runtime observation + // disagrees with static or runtime ownership, a later duplicate must not + // restore authoritative status. + target.properties.runtimeConfirmed = + target.properties.runtimeConfirmed === false ? false : confirmed; + // Source records provenance, not authority. Consumers MUST use + // runtimeConfirmed === true before treating runtime evidence as confirmed. + target.properties.runtimeSource = 'spring-actuator'; + const previousStatus = safeText(target.properties.runtimeStatus); + target.properties.runtimeStatus = [...new Set([...(previousStatus?.split(',') ?? []), status])] + .sort() + .join(','); + } + const marker = `Spring Actuator ${endpoint} ${status}`; + appendRuntimeMarker(target, marker); + + const evidence = evidenceFile(graph, endpoint); + graph.addRelationship({ + id: generateId('DECLARES', `${evidence.id}->${target.id}:${status}`), + sourceId: evidence.id, + targetId: target.id, + type: 'DECLARES', + confidence: 1, + reason: `spring-actuator:${endpoint}:${status}`, + }); +} + +function normalizedQualifiedName(value: string): string { + return value + .replace(/\$\$(?:SpringCGLIB|EnhancerBySpringCGLIB|FastClassBySpringCGLIB).*$/, '') + .replaceAll('$', '.'); +} + +function uniqueIndexAdd(index: Map, key: string, node: GraphNode): void { + const existing = index.get(key); + if (existing === undefined) index.set(key, node); + else if (existing !== null && existing.id !== node.id) index.set(key, null); +} + +interface RuntimeNodeIndexes { + readonly classesByQualifiedName: Map; + readonly classesByRuntimeAlias: Map; + readonly classesBySimpleName: Map; + readonly beanProvidersByName: Map; + readonly methodsByOwnerId: Map; + readonly callablesByRuntimeOwner: Map; + readonly routeOwnerFileIdsByRouteId: Map>; +} + +function addRuntimeCallable( + index: Map, + ownerName: string, + node: GraphNode, +): void { + const normalizedOwner = normalizedQualifiedName(ownerName); + const nodes = index.get(normalizedOwner) ?? []; + if (!nodes.some((candidate) => candidate.id === node.id)) nodes.push(node); + index.set(normalizedOwner, nodes); +} + +function buildRuntimeNodeIndexes(graph: KnowledgeGraph): RuntimeNodeIndexes { + const allNodes = [...graph.iterNodes()]; + const classesByQualifiedName = new Map(); + const classesByRuntimeAlias = new Map(); + const classesBySimpleName = new Map(); + const beanProvidersByName = new Map(); + const nodesById = new Map(allNodes.map((node) => [node.id, node])); + const methodsByOwnerId = new Map(); + const callablesByRuntimeOwner = new Map(); + const routeOwnerFileIdsByRouteId = new Map>(); + for (const node of allNodes) { + if (node.label === 'Class' || node.label === 'Record') { + const qualified = safeText(node.properties.qualifiedName); + if (qualified !== undefined) { + uniqueIndexAdd(classesByQualifiedName, normalizedQualifiedName(qualified), node); + } + uniqueIndexAdd(classesBySimpleName, String(node.properties.name), node); + } + const provider = objectValue(node.properties[SPRING_DI_PROVIDER_PROPERTY]); + for (const name of safeStrings(provider?.names)) + uniqueIndexAdd(beanProvidersByName, name, node); + } + const ownedNodeIds = new Set(); + for (const relationshipType of ['HAS_METHOD', 'HAS_PROPERTY'] as const) { + for (const relationship of graph.iterRelationshipsByType(relationshipType)) { + const member = nodesById.get(relationship.targetId); + const owner = nodesById.get(relationship.sourceId); + if ( + member === undefined || + !['Method', 'Function', 'Property'].includes(member.label) || + owner === undefined + ) { + continue; + } + ownedNodeIds.add(member.id); + if (member.label === 'Method' || member.label === 'Function') { + const methods = methodsByOwnerId.get(relationship.sourceId) ?? []; + methods.push(member); + methodsByOwnerId.set(relationship.sourceId, methods); + } + const ownerQualifiedName = safeText(owner.properties.qualifiedName); + if (ownerQualifiedName !== undefined) { + addRuntimeCallable(callablesByRuntimeOwner, ownerQualifiedName, member); + } + const strategy = getProviderForFile( + String(member.properties.filePath), + )?.runtimeSymbolStrategy; + for (const alias of strategy?.callableOwnerAliases?.(member, owner) ?? []) { + addRuntimeCallable(callablesByRuntimeOwner, alias, member); + if ( + (owner.label === 'Class' || owner.label === 'Record') && + ownerQualifiedName !== undefined && + normalizedQualifiedName(alias) !== normalizedQualifiedName(ownerQualifiedName) + ) { + uniqueIndexAdd(classesByRuntimeAlias, normalizedQualifiedName(alias), owner); + } + } + } + } + for (const node of allNodes) { + if ( + ownedNodeIds.has(node.id) || + (node.label !== 'Function' && node.label !== 'Method' && node.label !== 'Property') + ) { + continue; + } + const strategy = getProviderForFile(String(node.properties.filePath))?.runtimeSymbolStrategy; + for (const alias of strategy?.callableOwnerAliases?.(node, undefined) ?? []) { + addRuntimeCallable(callablesByRuntimeOwner, alias, node); + } + } + for (const relationship of graph.iterRelationshipsByType('HANDLES_ROUTE')) { + const owners = routeOwnerFileIdsByRouteId.get(relationship.targetId) ?? new Set(); + owners.add(relationship.sourceId); + routeOwnerFileIdsByRouteId.set(relationship.targetId, owners); + } + return { + classesByQualifiedName, + classesByRuntimeAlias, + classesBySimpleName, + beanProvidersByName, + methodsByOwnerId, + callablesByRuntimeOwner, + routeOwnerFileIdsByRouteId, + }; +} + +function resolveClass( + indexes: RuntimeNodeIndexes, + rawType: string | undefined, +): GraphNode | undefined { + if (rawType === undefined) return undefined; + const type = normalizedQualifiedName(rawType.replace(/\[\]$/, '')); + const exact = indexes.classesByQualifiedName.get(type); + if (exact !== null && exact !== undefined) return exact; + const alias = indexes.classesByRuntimeAlias.get(type); + if (alias !== null && alias !== undefined) return alias; + // A qualified runtime name is authoritative. Falling back to a unique class + // with the same simple name can bind a stale snapshot to a different package + // and then mint confidence-1 handler evidence for the wrong source. + if (type.includes('.')) return undefined; + const simple = type.slice(type.lastIndexOf('.') + 1); + const fallback = indexes.classesBySimpleName.get(simple); + return fallback === null ? undefined : fallback; +} + +function providerMatchesRuntimeType( + indexes: RuntimeNodeIndexes, + providerNode: GraphNode, + runtimeType: string | undefined, +): boolean { + if (runtimeType === undefined) return true; + const provider = objectValue(providerNode.properties[SPRING_DI_PROVIDER_PROPERTY]); + const providerType = + safeText(provider?.providedTypeName) ?? + (providerNode.label === 'Class' || providerNode.label === 'Record' + ? safeText(providerNode.properties.qualifiedName) + : undefined); + if (providerType === undefined) return true; + + const providerClass = resolveClass(indexes, providerType); + const runtimeClass = resolveClass(indexes, runtimeType); + if (providerClass !== undefined && runtimeClass !== undefined) { + return providerClass.id === runtimeClass.id; + } + + const normalizedProvider = normalizedQualifiedName(providerType); + const normalizedRuntime = normalizedQualifiedName(runtimeType); + if (normalizedProvider.includes('.')) return normalizedProvider === normalizedRuntime; + return normalizedProvider === normalizedRuntime.slice(normalizedRuntime.lastIndexOf('.') + 1); +} + +function descriptorParameterTypes(descriptor: string | undefined): string[] | undefined { + if (descriptor === undefined || descriptor.charAt(0) !== '(') return undefined; + const types: string[] = []; + for (let index = 1; index < descriptor.length && descriptor.charAt(index) !== ')'; ) { + let arrayDimensions = 0; + while (descriptor.charAt(index) === '[') { + arrayDimensions++; + index++; + } + const arraySuffix = '[]'.repeat(arrayDimensions); + if (descriptor.charAt(index) === 'L') { + const end = descriptor.indexOf(';', index); + if (end === -1) return undefined; + types.push(`${descriptor.slice(index + 1, end)}${arraySuffix}`); + index = end + 1; + } else { + const primitive = descriptor.charAt(index); + if (!'BCDFIJSZ'.includes(primitive)) return undefined; + types.push(`${primitive}${arraySuffix}`); + index++; + } + } + return descriptor.includes(')') ? types : undefined; +} + +function matchesRuntimeCallable(node: GraphNode, runtime: RuntimeCallableIdentity): boolean { + const strategy = getProviderForFile(String(node.properties.filePath))?.runtimeSymbolStrategy; + if (strategy !== undefined) return strategy.matchesCallable(node, runtime); + return ( + (node.label === 'Method' || node.label === 'Function') && + node.properties.name === runtime.name && + (runtime.descriptorParameterTypes === undefined || + node.properties.parameterCount === runtime.descriptorParameterTypes.length) + ); +} + +function resolveHandlerNode( + indexes: RuntimeNodeIndexes, + handlerMethod: JsonObject | undefined, +): GraphNode | undefined { + const className = safeText(handlerMethod?.className); + const methodName = safeText(handlerMethod?.name); + if (methodName === undefined) return resolveClass(indexes, className); + if (className === undefined) return undefined; + const owner = resolveClass(indexes, className); + const runtime: RuntimeCallableIdentity = { + name: methodName, + descriptorParameterTypes: descriptorParameterTypes(safeText(handlerMethod?.descriptor)), + }; + const ownerCandidates = owner === undefined ? [] : (indexes.methodsByOwnerId.get(owner.id) ?? []); + const aliasCandidates = + indexes.callablesByRuntimeOwner.get(normalizedQualifiedName(className)) ?? []; + const candidates = [...ownerCandidates, ...aliasCandidates] + .filter((node, index, all) => all.findIndex((candidate) => candidate.id === node.id) === index) + .filter((node) => matchesRuntimeCallable(node, runtime)); + return candidates.length === 1 ? candidates[0] : undefined; +} + +function predicateParts(predicate: string | undefined): { + readonly methods: string[]; + readonly patterns: string[]; +} { + if (predicate === undefined) return { methods: [], patterns: [] }; + const methodListEnd = predicate.indexOf('['); + const methodRegion = methodListEnd === -1 ? predicate : predicate.slice(0, methodListEnd); + const methods = [ + ...methodRegion.matchAll(/\b(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS|TRACE|CONNECT)\b/g), + ] + .map((match) => match[1]) + .filter((method): method is string => method !== undefined); + const patterns = [...predicate.matchAll(/(?:^|[\s[(])((?:\/)[^\s\]),}]+)/g)] + .map((match) => safeText(match[1])) + .filter((pattern): pattern is string => pattern !== undefined); + return { methods: [...new Set(methods)], patterns: [...new Set(patterns)] }; +} + +function mappingEntries(payload: JsonObject): { + entries: JsonObject[]; + truncated: boolean; +} { + const entries: JsonObject[] = []; + const contexts = objectValue(payload.contexts); + if (contexts === undefined) return { entries, truncated: false }; + for (const context of Object.values(contexts)) { + const mappings = objectValue(objectValue(context)?.mappings); + if (mappings === undefined) continue; + for (const groupName of ['dispatcherServlets', 'dispatcherHandlers']) { + const groups = objectValue(mappings[groupName]); + if (groups === undefined) continue; + for (const group of Object.values(groups)) { + if (!Array.isArray(group)) continue; + for (const entry of group) { + const object = objectValue(entry); + if (object !== undefined) entries.push(object); + if (entries.length > MAX_RUNTIME_RECORDS) { + entries.pop(); + return { entries, truncated: true }; + } + } + } + } + } + return { entries, truncated: false }; +} + +interface RuntimeMappingCandidate { + readonly key: string; + readonly method: string | undefined; + readonly url: string; + readonly handler: GraphNode | undefined; +} + +function importMappings( + graph: KnowledgeGraph, + payload: JsonObject, + indexes: RuntimeNodeIndexes, +): ImportResult { + let imported = 0; + const payloadEntries = mappingEntries(payload); + let truncated = payloadEntries.truncated; + const candidatesByKey = new Map(); + for (const entry of payloadEntries.entries) { + const details = objectValue(entry.details); + const conditions = objectValue(details?.requestMappingConditions); + const predicate = predicateParts(safeText(entry.predicate)); + const patterns = safeStrings(conditions?.patterns); + const methods = safeStrings(conditions?.methods) + .map(normalizeRouteMethod) + .filter((method): method is string => method !== undefined); + const effectivePatterns = patterns.length > 0 ? patterns : predicate.patterns; + const effectiveMethods = methods.length > 0 ? methods : predicate.methods; + if (effectivePatterns.length === 0) continue; + + const handler = resolveHandlerNode(indexes, objectValue(details?.handlerMethod)); + for (const rawPattern of effectivePatterns) { + const url = normalizeExtractedRoutePath(rawPattern, null); + for (const method of effectiveMethods.length > 0 ? effectiveMethods : [undefined]) { + const normalizedMethod = normalizeRouteMethod(method); + const key = routeNodeKey(normalizedMethod, url); + const candidate = { key, method: normalizedMethod, url, handler }; + const existing = candidatesByKey.get(key); + if (existing === undefined) { + if (candidatesByKey.size >= MAX_RUNTIME_RECORDS) { + truncated = true; + continue; + } + candidatesByKey.set(key, [candidate]); + } else { + existing.push(candidate); + } + } + } + } + + for (const candidates of candidatesByKey.values()) { + const first = candidates[0]; + if (first === undefined) continue; + const { key, method: normalizedMethod, url } = first; + const resolvedHandlers = new Map( + candidates + .map((candidate) => candidate.handler) + .filter((handler): handler is GraphNode => handler !== undefined) + .map((handler) => [handler.id, handler]), + ); + const runtimeHandlerConflict = resolvedHandlers.size > 1; + const handler = runtimeHandlerConflict ? undefined : resolvedHandlers.values().next().value; + const exactId = generateId('Route', key); + const fallbackId = generateId('Route', url); + let route = graph.getNode(exactId) ?? graph.getNode(fallbackId); + if (route?.label !== 'Route') route = undefined; + const routeWasPresent = route !== undefined; + if (route === undefined) { + route = { + id: exactId, + label: 'Route', + properties: { + name: url, + filePath: handler?.properties.filePath ?? `${RUNTIME_FILE_PREFIX}mappings`, + ...(normalizedMethod === undefined ? {} : { method: normalizedMethod }), + ...(handler === undefined ? {} : { handlerSymbolId: handler.id }), + }, + }; + graph.addNode(route); + } + const existingHandlerId = safeText(route.properties.handlerSymbolId); + const handlerFilePath = + handler !== undefined && typeof handler.properties.filePath === 'string' + ? handler.properties.filePath + : undefined; + const handlerFileId = + handlerFilePath === undefined ? undefined : generateId('File', handlerFilePath); + const staticOwnerFileIds = indexes.routeOwnerFileIdsByRouteId.get(route.id); + const conflictsWithStaticOwner = + routeWasPresent && + handlerFileId !== undefined && + staticOwnerFileIds !== undefined && + [...staticOwnerFileIds].some((ownerFileId) => ownerFileId !== handlerFileId); + if ( + runtimeHandlerConflict || + (handler !== undefined && + ((existingHandlerId !== undefined && existingHandlerId !== handler.id) || + conflictsWithStaticOwner)) + ) { + // Static ownership and runtime ownership disagree. Preserve the + // static handler, persist an explicit conflict, and do not mint an + // authoritative HANDLES_ROUTE edge from the runtime candidate. + markRuntimeEvidence(graph, 'mappings', route, 'handler-conflict', false); + imported++; + continue; + } + if (handler !== undefined && existingHandlerId === undefined) { + route.properties.handlerSymbolId = handler.id; + } + markRuntimeEvidence(graph, 'mappings', route); + if (handler !== undefined && handlerFileId !== undefined) { + if (graph.getNode(handlerFileId) !== undefined) { + graph.addRelationship({ + id: generateId('HANDLES_ROUTE', `${handlerFileId}->${route.id}`), + sourceId: handlerFileId, + targetId: route.id, + type: 'HANDLES_ROUTE', + confidence: 1, + reason: 'spring-actuator:runtime-confirmed', + }); + } + } + imported++; + } + return { count: imported, truncated }; +} + +function contextObjects(payload: JsonObject): JsonObject[] { + const contexts = objectValue(payload.contexts); + if (contexts === undefined) return []; + return Object.values(contexts) + .map(objectValue) + .filter((context): context is JsonObject => context !== undefined); +} + +function importBeans( + graph: KnowledgeGraph, + payload: JsonObject, + indexes: RuntimeNodeIndexes, +): ImportResult { + let imported = 0; + const seen = new Set(); + for (const [contextIndex, context] of contextObjects(payload).entries()) { + const beans = objectValue(context.beans); + if (beans === undefined) continue; + for (const [rawBeanName, rawBean] of Object.entries(beans)) { + if (imported >= MAX_RUNTIME_RECORDS) return { count: imported, truncated: true }; + const beanName = safeText(rawBeanName, 512); + const bean = objectValue(rawBean); + if (beanName === undefined || bean === undefined) continue; + const identity = `${contextIndex}:${beanName}`; + if (seen.has(identity)) continue; + seen.add(identity); + const type = safeText(bean.type, 1024); + const named = indexes.beanProvidersByName.get(beanName); + let target = named === null ? undefined : named; + if (target !== undefined && !providerMatchesRuntimeType(indexes, target, type)) { + target = undefined; + } + target ??= resolveClass(indexes, type); + if (target === undefined) { + const id = generateId('CodeElement', `spring-runtime-bean:${identity}`); + target = graph.getNode(id); + if (target === undefined) { + const scope = safeText(bean.scope, 128); + target = { + id, + label: 'CodeElement', + properties: { + name: beanName, + filePath: `${RUNTIME_FILE_PREFIX}beans`, + description: + `Spring runtime Bean ${beanName}` + + (type === undefined ? '' : ` of type ${type}`) + + (scope === undefined ? '' : ` (${scope})`), + ...(type === undefined ? {} : { qualifiedName: normalizedQualifiedName(type) }), + }, + }; + graph.addNode(target); + } + } + markRuntimeEvidence(graph, 'beans', target); + imported++; + } + } + return { count: imported, truncated: false }; +} + +function resolveConditionOwner( + indexes: RuntimeNodeIndexes, + rawName: string, +): GraphNode | undefined { + const separator = rawName.lastIndexOf('#'); + return resolveHandlerNode(indexes, { + className: separator === -1 ? rawName : rawName.slice(0, separator), + ...(separator === -1 ? {} : { name: rawName.slice(separator + 1) }), + }); +} + +function importConditions( + graph: KnowledgeGraph, + payload: JsonObject, + indexes: RuntimeNodeIndexes, +): ImportResult { + let imported = 0; + const seen = new Set(); + for (const context of contextObjects(payload)) { + for (const [field, status] of [ + ['positiveMatches', 'matched'], + ['negativeMatches', 'not-matched'], + ] as const) { + const matches = objectValue(context[field]); + if (matches === undefined) continue; + for (const rawName of Object.keys(matches)) { + if (imported >= MAX_RUNTIME_RECORDS) return { count: imported, truncated: true }; + const name = safeText(rawName); + if (name === undefined || seen.has(`${status}:${name}`)) continue; + seen.add(`${status}:${name}`); + const owner = resolveConditionOwner(indexes, name); + if (owner === undefined) continue; + // Actuator reports this status for the aggregate owner entry. Its child + // details may contain a mix of matched and not-matched conditions, but + // do not carry a stable identifier that maps to our CONDITIONAL_ON + // targets. Keep the aggregate on the owner instead of guessing. + markRuntimeEvidence(graph, 'conditions', owner, status); + imported++; + } + } + } + return { count: imported, truncated: false }; +} + +function relaxedPropertyName(value: string): string { + return value.toLowerCase().replace(/[-_.\[\]]/g, ''); +} + +interface RuntimePropertyIndex { + readonly exact: Map; + readonly relaxed: Map; +} + +function buildRuntimePropertyIndex(graph: KnowledgeGraph): RuntimePropertyIndex { + const exact = new Map(); + const relaxed = new Map(); + for (const node of graph.iterNodes()) { + if (node.label !== 'Property') continue; + const description = safeText(node.properties.description) ?? ''; + if (!description.startsWith(SPRING_CONFIG_DESCRIPTION) && !node.id.includes('spring-runtime')) { + continue; + } + const name = String(node.properties.name); + uniqueIndexAdd(exact, name, node); + uniqueIndexAdd(relaxed, relaxedPropertyName(name), node); + } + return { exact, relaxed }; +} + +function ensureRuntimeProperty( + graph: KnowledgeGraph, + index: RuntimePropertyIndex, + endpoint: 'configprops' | 'env', + rawName: string, +): GraphNode | undefined { + const name = safeText(rawName, 1024); + if (name === undefined) return undefined; + const exact = index.exact.get(name); + let node = exact === null ? undefined : exact; + if (node === undefined) { + const relaxed = index.relaxed.get(relaxedPropertyName(name)); + node = relaxed === null ? undefined : relaxed; + } + if (node === undefined) { + const id = generateId('Property', `spring-runtime-config:${name}`); + node = graph.getNode(id); + if (node === undefined) { + node = { + id, + label: 'Property', + properties: { + name, + filePath: `${RUNTIME_FILE_PREFIX}${endpoint}`, + description: `${SPRING_CONFIG_DESCRIPTION}; imported from Spring Actuator ${endpoint}`, + }, + }; + graph.addNode(node); + } + uniqueIndexAdd(index.exact, name, node); + uniqueIndexAdd(index.relaxed, relaxedPropertyName(name), node); + } + markRuntimeEvidence(graph, endpoint, node); + return node; +} + +function configInputPaths(inputs: unknown): { + paths: string[]; + truncated: boolean; +} { + const out: string[] = []; + const stack: Array<{ value: unknown; prefix: string; depth: number }> = [ + { value: inputs, prefix: '', depth: 0 }, + ]; + while (stack.length > 0 && out.length < MAX_RUNTIME_RECORDS) { + const current = stack.pop(); + if (current === undefined || current.depth > MAX_RUNTIME_DEPTH) continue; + const object = objectValue(current.value); + if (object === undefined) { + if (current.prefix.length > 0) out.push(current.prefix); + continue; + } + const keys = Object.keys(object); + const metadataLeaf = + keys.length === 0 || keys.every((key) => key === 'value' || key === 'origin'); + if (metadataLeaf) { + if (current.prefix.length > 0) out.push(current.prefix); + continue; + } + for (let index = keys.length - 1; index >= 0; index--) { + const rawKey = keys[index]; + if (rawKey === undefined) continue; + const key = safeText(rawKey, 256); + if (key === undefined) continue; + stack.push({ + value: object[rawKey], + prefix: current.prefix.length === 0 ? key : `${current.prefix}.${key}`, + depth: current.depth + 1, + }); + } + } + return { paths: out, truncated: stack.length > 0 }; +} + +function importConfigProperties( + graph: KnowledgeGraph, + payload: JsonObject, + propertyIndex: RuntimePropertyIndex, +): ImportResult { + let imported = 0; + let truncated = false; + const seen = new Set(); + for (const context of contextObjects(payload)) { + const beans = objectValue(context.beans); + if (beans === undefined) continue; + for (const rawBean of Object.values(beans)) { + const bean = objectValue(rawBean); + const prefix = safeText(bean?.prefix, 512)?.replace(/\.+$/, ''); + if (bean === undefined || prefix === undefined) continue; + const inputPaths = configInputPaths(bean.inputs); + truncated ||= inputPaths.truncated; + const names = + inputPaths.paths.length === 0 + ? [prefix] + : inputPaths.paths.map((entry) => `${prefix}.${entry}`); + for (const name of names) { + if (imported >= MAX_RUNTIME_RECORDS) return { count: imported, truncated: true }; + if (seen.has(name)) continue; + seen.add(name); + if (ensureRuntimeProperty(graph, propertyIndex, 'configprops', name) !== undefined) + imported++; + } + } + } + return { count: imported, truncated }; +} + +function importEnvironmentProperties( + graph: KnowledgeGraph, + payload: JsonObject, + propertyIndex: RuntimePropertyIndex, +): ImportResult { + let imported = 0; + const seen = new Set(); + if (!Array.isArray(payload.propertySources)) return { count: imported, truncated: false }; + for (const rawSource of payload.propertySources) { + const properties = objectValue(objectValue(rawSource)?.properties); + if (properties === undefined) continue; + // Deliberately enumerate keys only. Never read, retain, interpolate, or log + // the corresponding {value, origin} objects. + for (const rawName of Object.keys(properties)) { + if (imported >= MAX_RUNTIME_RECORDS) return { count: imported, truncated: true }; + const name = safeText(rawName, 1024); + if (name === undefined || seen.has(name)) continue; + seen.add(name); + if (ensureRuntimeProperty(graph, propertyIndex, 'env', name) !== undefined) imported++; + } + } + return { count: imported, truncated: false }; +} + +/** + * Import explicitly supplied Spring Boot Actuator snapshots. Runtime evidence + * is additive: it confirms existing static nodes where possible and creates + * conservative synthetic Route/Bean/Property nodes otherwise. Raw payloads, + * condition messages, config values, env values, origins, and source names are + * never copied into graph properties or logs. + */ +export async function importSpringActuatorRuntime( + graph: KnowledgeGraph, + repoPath: string, + configuredPath: string, +): Promise { + const payloads = await loadPayloads(repoPath, configuredPath); + const stats: MutableImportStats = { + payloads: payloads.size, + mappings: 0, + beans: 0, + conditions: 0, + configProperties: 0, + environmentProperties: 0, + truncatedEndpoints: [], + }; + const indexes = buildRuntimeNodeIndexes(graph); + const propertyIndex = buildRuntimePropertyIndex(graph); + + const mappings = payloads.get('mappings'); + if (mappings !== undefined) { + const result = importMappings(graph, mappings, indexes); + stats.mappings = result.count; + if (result.truncated) stats.truncatedEndpoints.push('mappings'); + } + const beans = payloads.get('beans'); + if (beans !== undefined) { + const result = importBeans(graph, beans, indexes); + stats.beans = result.count; + if (result.truncated) stats.truncatedEndpoints.push('beans'); + } + const conditions = payloads.get('conditions'); + if (conditions !== undefined) { + const result = importConditions(graph, conditions, indexes); + stats.conditions = result.count; + if (result.truncated) stats.truncatedEndpoints.push('conditions'); + } + const configprops = payloads.get('configprops'); + if (configprops !== undefined) { + const result = importConfigProperties(graph, configprops, propertyIndex); + stats.configProperties = result.count; + if (result.truncated) stats.truncatedEndpoints.push('configprops'); + } + const env = payloads.get('env'); + if (env !== undefined) { + const result = importEnvironmentProperties(graph, env, propertyIndex); + stats.environmentProperties = result.count; + if (result.truncated) stats.truncatedEndpoints.push('env'); + } + return stats; +} diff --git a/gitnexus/src/core/ingestion/frameworks/spring/analysis-features.ts b/gitnexus/src/core/ingestion/frameworks/spring/analysis-features.ts index 37b5f2b14..722c53249 100644 --- a/gitnexus/src/core/ingestion/frameworks/spring/analysis-features.ts +++ b/gitnexus/src/core/ingestion/frameworks/spring/analysis-features.ts @@ -43,3 +43,20 @@ export const SPRING_AOP_FEATURE: AnalysisFeatureDescriptor = { version: 1, appliesTo: (filePaths) => filePaths.some(isJvmSourceFile), }; + +/** Durable completeness contract for scheduled, event, messaging, and job entry points (#2417). */ +export const SPRING_NON_HTTP_HANDLERS_FEATURE: AnalysisFeatureDescriptor = { + id: 'spring.non-http-handlers', + version: 1, + appliesTo: (filePaths) => filePaths.some(isJvmSourceFile), +}; + +/** + * Route/handler binding extraction, including vendor `@Win*Mapping` aliases. + * Existing indexes keep a stale Route set until this version is stamped. + */ +export const SPRING_ROUTE_BINDINGS_FEATURE: AnalysisFeatureDescriptor = { + id: 'spring.route-bindings', + version: 2, + appliesTo: (filePaths) => filePaths.some(isJvmSourceFile), +}; diff --git a/gitnexus/src/core/ingestion/frameworks/spring/annotation-arguments.ts b/gitnexus/src/core/ingestion/frameworks/spring/annotation-arguments.ts index 928f3e805..9754d40c8 100644 --- a/gitnexus/src/core/ingestion/frameworks/spring/annotation-arguments.ts +++ b/gitnexus/src/core/ingestion/frameworks/spring/annotation-arguments.ts @@ -124,7 +124,13 @@ export function parseSpringAnnotationArguments( const body = annotationText.slice(open + 1, close).trim(); if (body.length === 0) return []; const rawArguments = splitTopLevel(body, ','); - if (rawArguments === null || rawArguments.some((argument) => argument.length === 0)) return null; + if (rawArguments === null) return null; + // Kotlin (and some formatters) allow a trailing comma. An empty *middle* + // argument is still invalid and fail-closed. + while (rawArguments.at(-1)?.length === 0) { + rawArguments.pop(); + } + if (rawArguments.some((argument) => argument.length === 0)) return null; const parsed: SpringAnnotationArgument[] = []; for (const raw of rawArguments) { diff --git a/gitnexus/src/core/ingestion/frameworks/spring/argument-facts.ts b/gitnexus/src/core/ingestion/frameworks/spring/argument-facts.ts new file mode 100644 index 000000000..cb7c9cf9d --- /dev/null +++ b/gitnexus/src/core/ingestion/frameworks/spring/argument-facts.ts @@ -0,0 +1,140 @@ +/** + * One argument of a Spring annotation or of a messaging-template call, captured + * exactly as it is written in source. + * + * Capture-time facts are deliberately UNRESOLVED. When these facts are produced + * the file's imports are not finalized, constants declared in sibling files do + * not exist yet, and no configuration source has been read — so a captured + * `text` may be a string literal, a constant reference (`Destinations.ORDERS`), + * a property placeholder (`"${app.orders.topic}"`), or an arbitrary expression. + * Turning any of those into an address is a separate, later phase; nothing here + * may call a resolver. + * + * NOT the same thing as `SpringAnnotationArgument` in `annotation-arguments.ts`, + * and the two are deliberately not merged: + * + * - Source. This fact is built from AST nodes while the tree is in hand; + * `parseSpringAnnotationArguments` re-parses an annotation's `text` much + * later, from a string, with a hand-written delimiter scanner. + * - Failure. The text parser returns `null` when its scanner cannot balance + * the input, and a caller must decide what that means. There is no such + * state here: the grammar has already decided where each argument begins + * and ends. + * - Absence. The text parser answers `[]` both for `@Scheduled` and for + * `@Scheduled()`, because a string cannot tell "no list" from "empty list" + * without re-deriving it. Capture keeps the two apart — absent versus `[]` — + * so downstream code can rely on the distinction wherever arguments were + * read at all. A capture that reads them for only some of its facts says so + * on its own `args` field. + * - Scope. This fact also describes CALL arguments (`template.send(topic, p)`), + * which the annotation parser has no notion of. + * + * Collapsing them would mean giving the text parser a failure mode it cannot + * produce, or taking the three-state distinction away from capture. + */ +export interface SpringArgumentFact { + /** + * Argument name for a named argument, absent for a positional one. + * + * Both forms occur, and where the destination sits differs by construct. An + * annotation names it (`@KafkaListener(topics = ...)` versus + * `@RabbitListener(queues = ...)`). A call normally gives it by position + * (`kafkaTemplate.send(topic, payload)`) — always so in Java, which has no + * named arguments — but a Kotlin call may name its arguments whenever the + * callee is itself declared in Kotlin, and then the key is captured too. + */ + readonly name?: string; + /** + * Argument value in its source spelling — quotes, braces and casts intact, + * nothing resolved — after `normalizeSpringFactText`. That pass trims the + * text and collapses whitespace around the dots of a multi-line expression, + * so one destination written two ways yields one fact. It is the only + * rewrite; see the function for why formatting must not reach the data. + */ + readonly text: string; +} + +/** + * Join an expression that the source wrapped across lines, so that one + * expression has one spelling no matter where it was written. + * + * A receiver chain written as `outer\n .inner\n .kafkaTemplate`, and an + * argument written as `Destinations\n .ORDERS`, are the same expressions as + * their single-line spellings. Raw node text would carry the newline and the + * ENCLOSING BLOCK's indentation across the worker boundary, so the same + * expression at two nesting depths — or in a CRLF checkout — would not compare + * equal downstream. Receivers and arguments get the identical treatment on + * purpose: an inconsistent rule inside one fact is a trap for the phase that + * has to match a publish against a subscription. + * + * Only a run of whitespace that CONTAINS A NEWLINE and sits next to a dot is + * removed, and only OUTSIDE a string literal. Single-line spacing is left + * alone, so `registry.get("a . b").template` keeps its argument exactly as + * written; literal-awareness extends that to Java text blocks and Kotlin raw + * strings, whose embedded newlines are part of the value and must survive + * (`"""line-a\n.line-b"""` is not the same string as `"""line-a.line-b"""`). + * + * Wraps that are not adjacent to a dot (`"a" +\n "b"`) are left as written: + * normalizing them would have to reason about operators, and the same + * conservatism already applies to receivers. + */ +export function normalizeSpringFactText(text: string): string { + const trimmed = text.trim(); + // Fast path: the overwhelming majority of captured text is single-line. + if (!trimmed.includes('\n') && !trimmed.includes('\r')) return trimmed; + + let out = ''; + let index = 0; + let quote: '"""' | '"' | "'" | null = null; + while (index < trimmed.length) { + const char = trimmed[index] as string; + if (quote === '"""') { + if (trimmed.startsWith('"""', index)) { + out += '"""'; + index += 3; + quote = null; + continue; + } + out += char; + index += 1; + continue; + } + if (quote !== null) { + // A backslash escape is copied whole so that `"\\"` ends the literal and + // `"\""` does not. + if (char === '\\' && index + 1 < trimmed.length) { + out += trimmed.slice(index, index + 2); + index += 2; + continue; + } + if (char === quote) quote = null; + out += char; + index += 1; + continue; + } + if (trimmed.startsWith('"""', index)) { + quote = '"""'; + out += '"""'; + index += 3; + continue; + } + if (char === '"' || char === "'") { + quote = char; + out += char; + index += 1; + continue; + } + if (char === '.' || /\s/.test(char)) { + const separator = /^\s*\.\s*/.exec(trimmed.slice(index)); + if (separator !== null) { + const matched = separator[0]; + out += matched.includes('\n') ? '.' : matched; + index += matched.length; + continue; + } + } + out += char; + index += 1; + } + return out; +} diff --git a/gitnexus/src/core/ingestion/frameworks/spring/config-bindings.ts b/gitnexus/src/core/ingestion/frameworks/spring/config-bindings.ts index 0baba4e88..e08273167 100644 --- a/gitnexus/src/core/ingestion/frameworks/spring/config-bindings.ts +++ b/gitnexus/src/core/ingestion/frameworks/spring/config-bindings.ts @@ -3,6 +3,7 @@ import type { KnowledgeGraph } from '../../../graph/types.js'; import { generateId } from '../../../../lib/utils.js'; export const SPRING_CONFIG_DESCRIPTION = 'Spring configuration property'; +export const SPRING_CONFIG_UNRESOLVED_PREFIX = 'Spring config unresolved: '; export interface SpringValueConsumer { readonly kind: 'value'; @@ -41,7 +42,7 @@ function closestNode( } function markUnresolved(node: GraphNode, key: string): void { - const marker = `Spring config unresolved: ${key}`; + const marker = `${SPRING_CONFIG_UNRESOLVED_PREFIX}${key}`; const existing = typeof node.properties.description === 'string' ? node.properties.description : ''; if (existing.includes(marker)) return; diff --git a/gitnexus/src/core/ingestion/frameworks/spring/destinations.ts b/gitnexus/src/core/ingestion/frameworks/spring/destinations.ts new file mode 100644 index 000000000..f02386192 --- /dev/null +++ b/gitnexus/src/core/ingestion/frameworks/spring/destinations.ts @@ -0,0 +1,1216 @@ +import type { SpringArgumentFact } from './argument-facts.js'; +import type { SpringMessageProducerTemplate } from './message-producers.js'; + +/** + * Resolution of Spring async messaging DESTINATIONS — the broker address a + * `@KafkaListener` reads from or a `kafkaTemplate.send(...)` writes to. + * + * The capture layer records the destination argument exactly as written and + * resolves nothing (see `argument-facts.ts`). This module is the other half: + * it decides WHICH argument names the destination, then walks a four-step + * cascade to turn that argument's source text into an address. It is pure — + * no graph, no filesystem, no parser — so every rule below is unit-testable + * against a string, and `pipeline-phases/spring-destinations.ts` is left with + * only node and edge emission. + * + * ── THE INVARIANT THIS MODULE EXISTS TO PROTECT ────────────────────────── + * + * An address that could NOT be resolved must never become a shared identity. + * Two unrelated services that each merely write + * + * @KafkaListener(topics = "${app.topic}") + * + * have said nothing about each other. If the graph keyed a destination node on + * that placeholder text, they would land on one node and READ AS CONNECTED — + * and a false edge is worse than a missing one, because a missing edge is + * visible as a gap while a false one enters reports as a fact. + * + * So this module never returns a placeholder, a constant name, or any other + * unresolved spelling as an `address`. An unresolved candidate comes back as + * `{ kind: 'unresolved', reason }` with no address at all, and the phase keys + * such a node by its SOURCE LOCATION. A status flag would not have been + * enough: the two services would still share whatever key the node was minted + * from. Only withholding the key prevents the join. + * + * ── REFUSAL IS DATA ────────────────────────────────────────────────────── + * + * Every path that declines to produce an address records WHY, from a closed + * set ({@link SpringDestinationRefusal}). The measure of this feature is the + * unresolved fraction, so a silent `continue` would hide precisely the number + * that says whether it works. + */ + +/** + * Broker family behind a destination, as far as the syntax can attest. + * + * Part of the `Destination` node IDENTITY, not merely a label on it: the phase + * keys a resolved node by `(broker, address)` via the framework-neutral + * `ingestion/destination-key.ts`, so two brokers claiming one address are two + * ordinary nodes. Adding or renaming a member here therefore re-keys every node + * it applies to, which a full re-index absorbs and an incremental one does not + * — the destination layer is delete-alled and rebuilt graph-wide on every + * incremental writeback for exactly this class of reason. + * + * A member is only added when the SYNTAX attests to it. A guess here becomes a + * guess in the identity, and the cost of a wrong one is a real pair split in + * two (see `destinationNodeKey` for why that cost is nonetheless the cheaper + * of the two failures available). + */ +export type SpringDestinationBroker = + | 'kafka' + | 'rabbit' + | 'jms' + | 'pulsar' + | 'sqs' + | 'stream' + | 'integration'; + +/** + * Why a candidate produced no address. Closed set: each member is a distinct, + * countable diagnosis, and no path may decline without naming one. + * + * Members are split rather than merged wherever the two causes are different + * FACTS about the repository. The unresolved fraction is only useful if its + * breakdown says what to go and fix, and a bucket that means "either the + * capture could not read this or the source really did write it that way" + * answers neither question. + */ +export type SpringDestinationRefusal = + /** The annotation is a recognized listener but its arguments were never read + * — a CAPTURE limitation, not a statement about the source. */ + | 'annotation-arguments-unavailable' + /** The annotation's argument list was read and it was EMPTY: `@KafkaListener` + * with no elements at all. A real source-level gap, and deliberately not the + * same bucket as `annotation-arguments-unavailable` — see + * `SpringNonHttpHandlerAnnotationFact.args`, which keeps absent and `[]` + * apart precisely so a consumer of the fact does not have to guess. */ + | 'annotation-arguments-empty' + /** Recognized listener, argument list present, no element names a destination. */ + | 'no-destination-argument' + /** `@KafkaListeners({@KafkaListener(...), ...})` and its siblings. The + * container's single argument is a list of NESTED annotations, and capture + * does not descend into them, so their destinations are unreadable here. + * Recorded rather than skipped: a repository using repeated-listener + * containers loses real destinations, and that has to show up in the count + * instead of looking like a repository with no listeners. */ + | 'repeated-listener-container' + /** `@KafkaListener(topicPattern = ...)` — a regex over topics, not an address. */ + | 'topic-pattern' + /** A destination form this module deliberately does not read, e.g. + * `@RabbitListener(bindings = @QueueBinding(...))` or `topicPartitions`. */ + | 'unsupported-annotation-argument' + /** A Kotlin trailing-lambda call: the publish has no argument list at all. */ + | 'producer-arguments-unavailable' + /** The call's arity matches none of the overloads that carry a destination. */ + | 'producer-arity-unrecognized' + /** The call used NAMED arguments and none of them names a destination + * parameter this module knows. Selecting by position instead would read + * whatever the author happened to write first — see + * {@link selectProducerDestinationArguments}. */ + | 'producer-named-argument-unrecognized' + /** `rabbitTemplate.convertAndSend(message)` — default exchange, empty routing + * key. There is no address in the source to record. */ + | 'rabbit-default-exchange' + /** The argument in the destination position is not shaped like an address + * (not a string literal, not a constant reference) — most often because the + * overload actually taken has the payload there. */ + | 'producer-argument-not-address-shaped' + /** Two overloads fit the call, they disagree about which slot is the address, + * and the argument is spelled the same way under both readings. The + * archetype is `convertAndSend("orders.rk", "body", correlationData)`: it is + * `(exchange, routingKey, message)` with the address `"body"`, or + * `(routingKey, message, correlationData)` with the address `"orders.rk"` + * and `"body"` as a String PAYLOAD. Both are real overloads spelled + * (String, String, ref). + * + * Distinct from `producer-argument-not-address-shaped`, which says the slot + * cannot hold an address at all. This one says it can, twice, and the module + * will not pick — a payload published as an address joins a consumer of a + * queue that happens to be named after the payload's text. */ + | 'ambiguous-producer-overload' + /** `topics = {}` / `topics = []` / `arrayOf()`. */ + | 'empty-destination-list' + /** The element is an expression this module will not evaluate — a + * concatenation, a call, a ternary. */ + | 'not-a-literal-or-constant' + /** A constant reference no constant resolver could fold to a string. */ + | 'unresolved-constant' + /** `#{...}` — a SpEL expression, evaluated by the container against beans and + * the environment at RUNTIME. `#{@kafkaProps.ordersTopic}` is the archetypal + * unresolvable address: nothing in the source says what it evaluates to, and + * two services that merely wrote the same expression have said nothing about + * each other. */ + | 'spel-expression' + /** An unescaped `$` interpolation in a language whose string literals + * interpolate. In Kotlin `"orders-$env"` and `"orders-${env}"` are STRING + * TEMPLATES evaluated at runtime, not addresses and not Spring placeholders + * — the escaped `"\${app.topic}"` is how a Spring placeholder has to be + * written there. Java does not interpolate, so `$` is an ordinary character + * and this never fires for it. */ + | 'unescaped-interpolation' + /** `${key}` with no default. The KEY is recorded; the VALUE is deliberately + * absent from the graph (config values may hold credentials — see the header + * of `pipeline-phases/spring-config.ts`), so this can never resolve here. */ + | 'unresolved-config-key' + /** `${key:default}`. The default IS written in the source, and it is kept on + * the node — but it is not an IDENTITY. It holds only while the key is not + * overridden in configuration, and configuration VALUES are deliberately + * absent from this graph, so the code cannot know whether it holds. Keying + * on it merges every service that copy-pasted the same fallback: `${a:events}` + * and `${b:events}` are two different addresses that happen to share a + * default. Both the key and the default text survive as properties, so the + * case stays countable and distinguishable from a bare `${key}`. */ + | 'overridable-config-default' + /** `${}` — a placeholder that names no key. There is nothing to record and + * nothing to look up; kept separate so an empty key never reaches the + * `Property` lookup as if it were a real one. */ + | 'empty-config-key' + /** A string literal that is empty or nothing but whitespace. An empty address + * addresses nothing, and letting it through would give every such site one + * shared `''` identity — the same false join the placeholder rule prevents. */ + | 'empty-literal-address' + /** A constant reference that folded to an empty or whitespace-only string. + * Same outcome as `empty-literal-address`, different repository fact: there + * the source wrote `""`, here a constant declaration did. */ + | 'empty-constant-address'; + +/** + * How an address was arrived at, kept on the node for provenance. + * + * There is deliberately no `config-default` member. A `${key:default}` does not + * resolve — see `overridable-config-default` — so no address can be reached + * that way. + */ +export type SpringDestinationVia = 'literal' | 'constant' | 'specification'; + +export type SpringDestinationRole = 'consumer' | 'producer'; + +/** + * One argument element that has been ACCEPTED as naming a destination, before + * any attempt to resolve it. An array-valued argument yields one candidate per + * element: `topics = ["a", "b"]` really is two destinations, and each gets its + * own node and its own edge (see the phase for why no group node is minted). + */ +export interface SpringDestinationCandidate { + readonly role: SpringDestinationRole; + /** Annotation simple name (`KafkaListener`) or producer template (`kafka`). */ + readonly source: string; + readonly broker: SpringDestinationBroker; + /** Index of the argument this element came from, in source order. */ + readonly argIndex: number; + /** Argument name when the call/annotation named it (`topics`, `queues`). */ + readonly argName?: string; + /** Index within an array-valued argument; `0` for a scalar. */ + readonly elementIndex: number; + /** The element's source text, exactly as captured. */ + readonly rawText: string; + /** Companion provenance that is not itself an address — currently only the + * Rabbit exchange that accompanies a routing key. */ + readonly exchange?: string; +} + +/** A candidate that was declined before resolution was even attempted. */ +export interface SpringDestinationRefusalRecord { + readonly role: SpringDestinationRole; + readonly source: string; + readonly broker: SpringDestinationBroker; + readonly reason: SpringDestinationRefusal; + /** Source text that provoked the refusal, when there was one. */ + readonly rawText?: string; + readonly argIndex?: number; + readonly argName?: string; +} + +export interface SpringDestinationSelection { + readonly candidates: readonly SpringDestinationCandidate[]; + readonly refusals: readonly SpringDestinationRefusalRecord[]; +} + +export type SpringDestinationResolution = + | { readonly kind: 'resolved'; readonly address: string; readonly via: SpringDestinationVia } + | { + readonly kind: 'unresolved'; + readonly reason: SpringDestinationRefusal; + /** Configuration key named by an unresolvable `${...}` placeholder. Lets + * the phase link the node to the `Property` nodes for that key without + * ever learning the key's value. */ + readonly configKey?: string; + /** Default text of a `${key:default}`, exactly as the source wrote it. + * Kept as PROVENANCE only — it is never an address and never a key, for + * the reason `overridable-config-default` gives. */ + readonly configDefault?: string; + }; + +/** + * The cascade's pluggable steps plus the one language capability it needs. + * + * The steps are supplied by the phase, which owns the language-specific + * machinery; keeping them as callbacks is what lets this module stay + * language-neutral and testable with a plain map. + */ +export interface SpringDestinationResolvers { + /** + * Whether the owning language INTERPOLATES string literals — Kotlin does, + * Java does not. A capability, deliberately not a language name: shared + * ingestion code may not branch on a language (see AGENTS.md), and the + * capability is also the thing that actually matters. Supplied alongside + * `getSpringMessagingFacts` by the provider and threaded in by the phase. + * + * When true, an unescaped `$` inside a literal is a runtime template and the + * candidate is refused. When false (the default) `$` is an ordinary + * character and `"${app.topic}"` is a Spring placeholder. + */ + readonly interpolatesStringLiterals?: boolean; + /** + * Step 2 — fold a constant reference (`Topics.ORDERS`, `ORDERS`) to its + * string value, or `null` when it cannot be folded. Backed by + * `resolveJavaConstant` / `resolveKotlinConstant`. + */ + readonly constant?: (name: string) => string | null; + /** + * Step 4 — SEAM, DELIBERATELY NOT IMPLEMENTED. + * + * Some destinations are named nowhere in the source: the address lives in a + * published API specification (AsyncAPI / springwolf) that the service + * generates, and the code only names a binding. Resolving those means reading + * an artifact that is not a source file, deciding which specification belongs + * to which module, and trusting a generated document — a different problem + * from the three syntactic steps above, with a different failure mode. + * + * The hook exists so that work has a defined place to land and so the cascade + * order is fixed now rather than renegotiated later. Nothing supplies it + * today, so step 4 is a no-op and such destinations stay unresolved with the + * reason the earlier step recorded. + */ + readonly specification?: (candidate: SpringDestinationCandidate) => string | null; +} + +// ── Consumer side: which annotation argument names the destination ───────── + +interface ConsumerAnnotationRule { + readonly broker: SpringDestinationBroker; + /** Argument names that carry an address, in preference order. */ + readonly addressArgs: readonly string[]; + /** + * A bare positional argument is the annotation's `value` element. Accepted + * only where `value` really is the destination: `@SqsListener("q")` and + * `@StreamListener("ch")`. `@KafkaListener`, `@RabbitListener`, `@JmsListener` + * and `@ServiceActivator` declare no `value` alias for their destination, so + * a positional argument on one of those is something else entirely and is + * refused rather than guessed at. + */ + readonly positionalIsAddress: boolean; + /** Arguments that are patterns over addresses, not addresses. */ + readonly patternArgs?: readonly string[]; + /** Arguments that name a destination in a shape this module will not read. */ + readonly unsupportedArgs?: readonly string[]; +} + +/** + * Recognized listener annotations, keyed by SIMPLE name. + * + * Simple names, not fully-qualified ones, because a pipeline phase runs after + * scope resolution has finished and no longer has the import tables that + * `createSpringAnnotationNameResolver` needs. The capture layer already gates + * on simple names for the same reason (`CAPTURE_RELEVANT_SIMPLE_NAMES` in + * `non-http-handlers.ts`), so nothing reaches this map that was not already + * admitted on that basis; matching on the FQN here would only reject facts the + * capture had already accepted, never admit more. + * + * DELIBERATELY ABSENT: `@MessageMapping` and `@SubscribeMapping`. Both are + * recognized by `non-http-handlers.ts` as message handlers, and both are + * WebSocket/STOMP routes — an application-level destination inside a + * server-managed session, not an address on a broker. Modelling `/topic/prices` + * as a `Destination` would put a STOMP path in the same namespace as a Kafka + * topic and let the cross-service joiner match them. + */ +const CONSUMER_ANNOTATIONS: ReadonlyMap = new Map([ + [ + 'KafkaListener', + { + broker: 'kafka' as const, + addressArgs: ['topics'], + positionalIsAddress: false, + patternArgs: ['topicPattern'], + unsupportedArgs: ['topicPartitions'], + }, + ], + [ + 'PulsarListener', + { + broker: 'pulsar' as const, + addressArgs: ['topics'], + positionalIsAddress: false, + patternArgs: ['topicPattern'], + }, + ], + [ + 'RabbitListener', + { + broker: 'rabbit' as const, + addressArgs: ['queues'], + positionalIsAddress: false, + unsupportedArgs: ['bindings', 'queuesToDeclare'], + }, + ], + [ + 'JmsListener', + { broker: 'jms' as const, addressArgs: ['destination'], positionalIsAddress: false }, + ], + [ + 'ServiceActivator', + { broker: 'integration' as const, addressArgs: ['inputChannel'], positionalIsAddress: false }, + ], + ['SqsListener', { broker: 'sqs' as const, addressArgs: ['value'], positionalIsAddress: true }], + [ + 'StreamListener', + { broker: 'stream' as const, addressArgs: ['value'], positionalIsAddress: true }, + ], +]); + +/** + * Plural container annotations (`@KafkaListeners`, `@RabbitListeners`, …) wrap + * repeated listeners. Their single argument is a list of nested annotations, + * whose own arguments the capture does not descend into, so there is nothing + * here to read. + * + * They are recognized rather than ignored so the loss is COUNTED. A repository + * that declares its listeners this way really does lose those destinations, and + * returning an empty selection would make it indistinguishable from a + * repository with no listeners at all — the module header promises that every + * path which declines to produce an address records why, and an empty + * `refusals` array records nothing. The broker comes from the container's own + * name, which is the one thing the annotation does state. + */ +const CONSUMER_CONTAINER_ANNOTATIONS: ReadonlyMap = new Map([ + ['KafkaListeners', 'kafka' as const], + ['RabbitListeners', 'rabbit' as const], + ['JmsListeners', 'jms' as const], + ['PulsarListeners', 'pulsar' as const], +]); + +function simpleName(name: string): string { + const separator = name.lastIndexOf('.'); + return separator === -1 ? name : name.slice(separator + 1); +} + +/** + * Choose the destination-bearing arguments of one listener annotation. + * + * Returns `null` when the annotation is not a broker listener at all — that is + * not a refusal, there was nothing to refuse. A recognized annotation always + * returns a selection, even when every path in it declined, so the caller can + * count what was seen against what resolved. + */ +export function selectConsumerDestinationArguments( + annotationName: string, + args: readonly SpringArgumentFact[] | undefined, +): SpringDestinationSelection | null { + const name = simpleName(annotationName); + const containerBroker = CONSUMER_CONTAINER_ANNOTATIONS.get(name); + if (containerBroker !== undefined) { + return { + candidates: [], + refusals: [ + { + role: 'consumer', + source: name, + broker: containerBroker, + reason: 'repeated-listener-container', + ...(args === undefined || args[0] === undefined ? {} : { rawText: args[0].text }), + }, + ], + }; + } + const rule = CONSUMER_ANNOTATIONS.get(name); + if (rule === undefined) return null; + + const refusals: SpringDestinationRefusalRecord[] = []; + const refuse = ( + reason: SpringDestinationRefusal, + extra: Omit = {}, + ): void => { + refusals.push({ role: 'consumer', source: name, broker: rule.broker, reason, ...extra }); + }; + + // ABSENT arguments are a capture limitation: the annotation was recognized + // but its argument list was never read (see + // `SpringNonHttpHandlerAnnotationFact.args`). An empty ARRAY is a different + // fact entirely — an argument list WAS read and it was empty, so the source + // really does declare a listener that names no destination. Capture keeps the + // two apart on purpose, the producer side of this module already does, and + // merging them here would file a source-level gap under a tooling gap and + // corrupt the one breakdown this feature is measured on. + if (args === undefined) { + refuse('annotation-arguments-unavailable'); + return { candidates: [], refusals }; + } + if (args.length === 0) { + refuse('annotation-arguments-empty'); + return { candidates: [], refusals }; + } + + const candidates: SpringDestinationCandidate[] = []; + let sawDestinationArgument = false; + for (const [argIndex, arg] of args.entries()) { + const argName = arg.name; + if (argName === undefined) { + // Positional. Only the annotations whose `value` element IS the + // destination accept it; on the others a positional argument is a + // different element entirely and gets no guess. + if (!rule.positionalIsAddress) continue; + sawDestinationArgument = true; + pushElements(candidates, refusals, { + role: 'consumer', + source: name, + broker: rule.broker, + argIndex, + rawText: arg.text, + }); + continue; + } + if (rule.patternArgs?.includes(argName)) { + sawDestinationArgument = true; + refuse('topic-pattern', { rawText: arg.text, argIndex, argName }); + continue; + } + if (rule.unsupportedArgs?.includes(argName)) { + sawDestinationArgument = true; + refuse('unsupported-annotation-argument', { rawText: arg.text, argIndex, argName }); + continue; + } + if (!rule.addressArgs.includes(argName)) continue; + sawDestinationArgument = true; + pushElements(candidates, refusals, { + role: 'consumer', + source: name, + broker: rule.broker, + argIndex, + argName, + rawText: arg.text, + }); + } + + // A listener whose arguments were read and named `groupId` and + // `containerFactory` but no destination is a real, countable gap — most often + // a form this module has not learned. It must not be silent. + if (!sawDestinationArgument) refuse('no-destination-argument'); + return { candidates, refusals }; +} + +// ── Producer side: which call argument names the destination ─────────────── + +/** + * Parameter names that carry a destination, per template, for calls that pass + * their arguments BY NAME. + * + * Kotlin call sites may name arguments, and a named argument list is in source + * order, not parameter order — `send(data = payload, topic = "orders")` is + * legal and puts the payload in slot 0. Reading slot 0 there publishes the + * PAYLOAD as an address. The name is captured + * ({@link SpringArgumentFact.name}), so the honest rule is to use it: select by + * name when there is one, and refuse when the names present say nothing this + * module recognizes. Selecting by position while ignoring a name that + * contradicts it is the one option that is never defensible. + * + * `exchange` is listed for rabbit but is NOT an address — it is the companion + * provenance the routing key carries (see the arity notes below). + */ +const PRODUCER_DESTINATION_PARAMETERS: Readonly< + Record +> = { + kafka: ['topic'], + // `RabbitTemplate.convertAndSend(String exchange, String routingKey, Object message, …)`. + rabbit: ['routingKey'], + // `JmsTemplate.convertAndSend(Destination destination, …)` and the + // `String destinationName` overloads. + jms: ['destination', 'destinationName'], + // `StreamBridge.send(String bindingName, Object data, …)`. + 'stream-bridge': ['bindingName'], +}; + +/** Rabbit's exchange parameter, carried as provenance rather than as an address. */ +const RABBIT_EXCHANGE_PARAMETER = 'exchange'; + +/** + * Choose the destination-bearing arguments of one messaging-template publish. + * + * A NAME beats a position, arity decides where it can decide, and shape decides + * where it cannot. + * + * When any argument is passed by name, {@link PRODUCER_DESTINATION_PARAMETERS} + * decides — position is not consulted at all, because a named argument list + * need not be in parameter order. When the slot this module would have read + * positionally is itself named with something it does not recognize, that is a + * contradiction and the publish is refused rather than read. + * + * `KafkaTemplate.send` and `StreamBridge.send` put the destination first in + * every multi-argument positional overload they have, so once such a call has + * two or more arguments its slot 0 is the destination and nothing further needs + * deciding. Those slots use the PERMISSIVE gate ({@link isAddressShaped}): a + * bare identifier is let through to the cascade, which refuses it by name if no + * constant folds. That keeps `unresolved-constant` — a thing we tried to + * resolve — distinct from `producer-argument-not-address-shaped`, a thing we + * declined to read at all. + * + * The `convertAndSend` families are different. Both admit trailing + * `MessagePostProcessor` and `CorrelationData` parameters, and arity does not + * separate the overloads in EITHER direction: + * + * jms (destination, message) 2 vs (message, postProcessor) 2 + * rabbit (routingKey, message) 2 vs (message, postProcessor) 2 + * rabbit (exchange, routingKey, message) 3 vs (routingKey, message, pp) 3 + * vs (routingKey, message, correlation) 3 + * rabbit (exchange, routingKey, message, pp) 4 vs (routingKey, message, pp, corr) 4 + * + * So the tie is broken by the STRICT gate ({@link isConfidentAddressShape}) — a + * string literal, a qualified reference, or a screaming-snake constant, all of + * which a payload variable is not. A lowercase bare identifier is NOT confident + * evidence, so `convertAndSend(topic, payload)` is refused rather than read: + * the same spelling is how a payload variable looks, and nothing in the syntax + * separates them. That refusal is the deliberate cost. A refusal is counted and + * recoverable; a wrong address enters reports as a fact. + * + * There is NO positional fallback at rabbit arity 3+. An earlier revision fell + * back to accepting slot 0 when slot 1 was not confident, which turned the + * ordinary `convertAndSend(EXCHANGE, routingKey, event)` — routing key in a + * variable — into a destination whose address was the EXCHANGE NAME. That is + * the worst possible outcome: an address that looks entirely plausible and can + * join a `@RabbitListener(queues = "orders")` that has nothing to do with it. + * + * ── THE ONE AMBIGUITY, REFUSED RATHER THAN GUESSED ─────────────────────── + * + * `convertAndSend("orders.rk", "body", correlationData)` fits two overloads at + * once and they disagree about which slot is the address: + * + * (exchange, routingKey, message) → the address is `"body"` + * (routingKey, message, correlationData) → the address is `"orders.rk"` + * + * Both are real, both are spelled (String, String, ref), and no rule over the + * syntax separates them. Picking either one publishes the OTHER reading's + * payload as an address, where a consumer of a queue named after that text + * joins a publisher that never wrote to it. So neither is picked: the call is + * refused as `ambiguous-producer-overload` and yields no candidate and no edge. + * + * The refusal is narrow on purpose, because over-refusing here costs the + * ordinary case, and a suppression that eats correct results is the more + * expensive mistake. It fires ONLY on a STRING LITERAL in slot 1, at the + * arities where a competing overload exists: + * + * - A literal is no evidence at all. An address and a payload are BOTH + * ordinarily written as literals, so the spelling distinguishes nothing. + * - A CONSTANT or QUALIFIED reference is evidence, which is the whole premise + * of {@link isConfidentAddressShape}: `ORDERS_ROUTING_KEY` and + * `Topics.ORDERS_KEY` are how a configured NAME is written, not how a + * payload computed at the call site is. Those keep resolving. + * - Arity 5 has no competing overload at all — + * `(exchange, routingKey, message, pp, correlationData)` is the only + * five-argument form — so slot 1 there is the routing key whatever it is + * spelled like, and the refusal must not reach it. + * - A NAMED argument settles the reading outright, and the name-beats-position + * pre-pass above has already returned by then. + */ +export function selectProducerDestinationArguments(fact: { + readonly template: SpringMessageProducerTemplate; + readonly methodName: string; + readonly args?: readonly SpringArgumentFact[]; +}): SpringDestinationSelection { + const broker: SpringDestinationBroker = + fact.template === 'stream-bridge' ? 'stream' : fact.template; + const source = fact.template; + const refusals: SpringDestinationRefusalRecord[] = []; + const refuse = ( + reason: SpringDestinationRefusal, + extra: Omit = {}, + ): void => { + refusals.push({ role: 'producer', source, broker, reason, ...extra }); + }; + + const args = fact.args; + if (args === undefined) { + refuse('producer-arguments-unavailable'); + return { candidates: [], refusals }; + } + if (args.length === 0) { + refuse('producer-arity-unrecognized'); + return { candidates: [], refusals }; + } + + const candidates: SpringDestinationCandidate[] = []; + const accept = (argIndex: number, exchange?: string): void => { + const arg = args[argIndex] as SpringArgumentFact; + pushElements(candidates, refusals, { + role: 'producer', + source, + broker, + argIndex, + ...(arg.name === undefined ? {} : { argName: arg.name }), + rawText: arg.text, + ...(exchange === undefined ? {} : { exchange }), + }); + }; + /** + * Accept a slot chosen by POSITION. + * + * Refuses when the argument in that slot carries a name — a named list need + * not be in parameter order, so a name in the destination slot that is not a + * destination parameter contradicts the position, and reading the position + * anyway is how `send(data = "payload", topic = "orders")` published the + * payload. The name-matching pre-pass above has already had its chance. + */ + const acceptPositional = (argIndex: number, exchange?: string): void => { + const arg = args[argIndex] as SpringArgumentFact; + if (arg.name !== undefined) { + refuse('producer-named-argument-unrecognized', { + rawText: arg.text, + argIndex, + argName: arg.name, + }); + return; + } + accept(argIndex, exchange); + }; + const textAt = (index: number): string => (args[index] as SpringArgumentFact).text; + const confident = (index: number): boolean => + index < args.length && isConfidentAddressShape(textAt(index)); + const refuseShape = (index: number): void => { + refuse('producer-argument-not-address-shaped', { rawText: textAt(index), argIndex: index }); + }; + + // ── A name beats a position ───────────────────────────────────────────── + // When an argument names a destination parameter, that argument IS the + // destination wherever it sits in the list. Only when no name matches does + // the positional reasoning below run, and `acceptPositional` then refuses if + // the slot it lands on turns out to be named after something else. + const destinationNames = PRODUCER_DESTINATION_PARAMETERS[fact.template]; + const namedIndex = args.findIndex( + (arg) => arg.name !== undefined && destinationNames.includes(arg.name), + ); + if (namedIndex !== -1) { + const exchangeIndex = + fact.template === 'rabbit' + ? args.findIndex((arg) => arg.name === RABBIT_EXCHANGE_PARAMETER) + : -1; + accept( + namedIndex, + exchangeIndex === -1 ? undefined : unquoteForProvenance(textAt(exchangeIndex)), + ); + return { candidates, refusals }; + } + + if (fact.template === 'rabbit') { + // `convertAndSend` overloads, by what occupies the leading slots: + // (message) → default exchange, no address + // (routingKey, message) → arg0 is the routing key + // (message, postProcessor) → NO address, same arity + // (exchange, routingKey, message) → arg0 + arg1 + // (routingKey, message, postProcessor) → arg0 only, same arity + // (routingKey, message, correlationData) → arg0 only, same arity + // (exchange, routingKey, message, pp) → arg0 + arg1 + // (routingKey, message, pp, correlationData) → arg0 only, same arity + // (exchange, routingKey, message, pp, corr) → arg0 + arg1 + if (args.length === 1) { + refuse('rabbit-default-exchange', { rawText: textAt(0), argIndex: 0 }); + return { candidates, refusals }; + } + if (args.length === 2) { + // (routingKey, message) versus (message, postProcessor). + if (confident(0)) { + acceptPositional(0); + return { candidates, refusals }; + } + refuseShape(0); + return { candidates, refusals }; + } + // Three arguments and up. Arity separates almost nothing here — three and + // four both admit an exchange form and a routing-key form — so the ONLY + // acceptance is confident evidence in slot 1, and there is no positional + // fallback. `convertAndSend(EXCHANGE, routingKey, event)` fails that test + // and is refused; the discarded fallback published `EXCHANGE` as the + // address, which is a wrong answer wearing the costume of a right one. + // + // And confident evidence in slot 1 is not enough when the evidence is a + // STRING LITERAL: under the competing overload that same literal is the + // String PAYLOAD, and the two readings are spelled identically. See the + // ambiguity section in this function's doc comment for why this is a + // refusal rather than a choice, and for each of the three cases it must not + // touch — a constant in slot 1 (spelling that IS evidence), arity 5 (no + // competing overload exists), and a named argument (already returned + // above, and its name settles the reading). + const competingOverload = args.length === 3 || args.length === 4; + if ( + competingOverload && + args[1]?.name === undefined && + parseSpringStringLiteral(textAt(1)) !== null + ) { + refuse('ambiguous-producer-overload', { rawText: textAt(1), argIndex: 1 }); + return { candidates, refusals }; + } + if (confident(1)) { + // The ADDRESS is the routing key. The exchange rides along as provenance + // on the edge rather than becoming part of the address: composing + // `exchange/routingKey` would invent a spelling no consumer ever writes, + // and a `@RabbitListener` names a QUEUE, so the two sides do not join on + // the exchange anyway. Which queue an exchange/key pair reaches is decided + // by bindings this index does not read. + acceptPositional(1, unquoteForProvenance(textAt(0))); + return { candidates, refusals }; + } + refuseShape(1); + return { candidates, refusals }; + } + + // kafka `send(topic, …)`, jms `convertAndSend(destination, message, …)` and + // stream-bridge `send(binding, …)` all put the destination first and all + // require at least one further argument for the payload. A single-argument + // call is therefore one of the payload-only overloads — + // `send(ProducerRecord)`, `send(Message)`, `convertAndSend(Object)` — which + // carries its destination inside an object this module does not open. + if (args.length < 2) { + refuse('producer-arity-unrecognized', { rawText: textAt(0), argIndex: 0 }); + return { candidates, refusals }; + } + // Two-argument `convertAndSend` is the one JMS arity that collides with the + // post-processor overload, so only there does slot 0 need confident evidence. + const strict = fact.template === 'jms' && args.length === 2; + if (strict ? !confident(0) : !isAddressShaped(textAt(0))) { + refuseShape(0); + return { candidates, refusals }; + } + acceptPositional(0); + return { candidates, refusals }; +} + +// ── Array / literal / placeholder text handling ──────────────────────────── + +/** + * Split an array-valued destination argument into its elements. + * + * Both languages hand this module ONE unsplit string per argument: capture + * records an argument's source text, and `topics = {"a", "b"}` is a single + * argument whose text happens to be a list. So the list is parsed here, in the + * three spellings the two languages use — Java `{…}`, Kotlin `[…]`, and Kotlin + * `arrayOf(…)`. + * + * Anything else comes back as a single element, unchanged: a scalar argument, + * and equally an expression that merely starts with a brace. The split tracks + * nesting and string literals, so a comma inside a literal or inside a nested + * call does not split the list. + * + * Returns `[]` for an empty list, which the caller must distinguish from a + * one-element list — `topics = {}` names no destination at all. + */ +export function splitSpringDestinationList(text: string): readonly string[] { + const trimmed = text.trim(); + let inner: string | null = null; + if (trimmed.startsWith('{') && trimmed.endsWith('}')) inner = trimmed.slice(1, -1); + else if (trimmed.startsWith('[') && trimmed.endsWith(']')) inner = trimmed.slice(1, -1); + else if (/^arrayOf\s*\(/.test(trimmed) && trimmed.endsWith(')')) { + inner = trimmed.slice(trimmed.indexOf('(') + 1, -1); + } + if (inner === null) return [trimmed]; + if (inner.trim() === '') return []; + + const elements: string[] = []; + let current = ''; + let depth = 0; + let quote: '"""' | '"' | "'" | null = null; + for (let index = 0; index < inner.length; index += 1) { + const char = inner[index] as string; + if (quote === '"""') { + current += char; + if (inner.startsWith('"""', index)) { + current += '""'; + index += 2; + quote = null; + } + continue; + } + if (quote !== null) { + if (char === '\\' && index + 1 < inner.length) { + current += inner.slice(index, index + 2); + index += 1; + continue; + } + current += char; + if (char === quote) quote = null; + continue; + } + if (inner.startsWith('"""', index)) { + current += '"""'; + index += 2; + quote = '"""'; + continue; + } + if (char === '"' || char === "'") { + quote = char; + current += char; + continue; + } + if (char === '(' || char === '[' || char === '{') depth += 1; + else if (char === ')' || char === ']' || char === '}') depth -= 1; + if (char === ',' && depth === 0) { + elements.push(current.trim()); + current = ''; + continue; + } + current += char; + } + elements.push(current.trim()); + return elements.filter((element) => element !== ''); +} + +/** + * ONE string literal, whole. + * + * The triple-quoted alternative excludes `"""` from its body rather than + * matching greedily: `"""a""" + """b"""` is a concatenation, not a literal, and + * a greedy body swallowed the operator and folded it to the single address + * `a""" + """b`. Excluding the terminator makes the whole-string anchor fail + * there, so the text falls through to the constant test and is refused as + * `not-a-literal-or-constant`, which is what it is. + */ +const STRING_LITERAL = + /^(?:"""((?:(?!""")[\s\S])*)"""|"((?:[^"\\]|\\[\s\S])*)"|'((?:[^'\\]|\\[\s\S])*)')$/; + +/** + * Unquote a string literal to its value, or `null` when the text is not a + * single literal. + * + * Escapes are undone only for the sequences that can appear inside a + * destination: `\"`, `\\`, and Kotlin's `\$`. That last one matters more than it + * looks — a Spring placeholder written in Kotlin MUST escape the dollar + * (`"\${app.topic}"`) or the compiler reads it as a string template, so without + * undoing it every Kotlin placeholder would fail the `${` test below and be + * misfiled as a plain literal address named `\${app.topic}`. + * + * The unescaping is also why {@link hasUnescapedStringInterpolation} has to run + * against the RAW text: once `\$` has become `$`, the escaped placeholder and + * the runtime template are the same string. + */ +export function parseSpringStringLiteral(text: string): string | null { + const match = STRING_LITERAL.exec(text.trim()); + if (match === null) return null; + const raw = match[1] ?? match[2] ?? match[3] ?? ''; + return raw.replace(/\\(["'\\$nrt])/g, (_all, escaped: string) => { + if (escaped === 'n') return '\n'; + if (escaped === 'r') return '\r'; + if (escaped === 't') return '\t'; + return escaped; + }); +} + +/** `$` followed by a brace or an identifier start — the two template forms. */ +const INTERPOLATION_START = /^[{A-Za-z_]/; + +/** + * Whether a string literal contains an UNESCAPED interpolation, for a language + * whose literals interpolate. + * + * Only meaningful for such a language; Java never calls it. In Kotlin: + * + * "orders-$env" template — the value is decided at runtime + * "orders-${env}" template — NOT a Spring placeholder + * "\${app.topic}" escaped — this is how a Spring placeholder is written + * """orders-$env""" template — raw strings interpolate and cannot escape + * + * Reads the RAW literal text on purpose: {@link parseSpringStringLiteral} + * resolves `\$` to `$`, after which the second and third rows above are the + * same string and the distinction is gone. A raw (`"""`) literal has no + * backslash escapes at all — `${'$'}` is the only way to write a dollar there — + * so every `$` in one is an interpolation. + */ +export function hasUnescapedStringInterpolation(text: string): boolean { + const trimmed = text.trim(); + const match = STRING_LITERAL.exec(trimmed); + if (match === null) return false; + const raw = match[1] ?? match[2] ?? match[3] ?? ''; + const escapable = match[1] === undefined; + for (let index = 0; index < raw.length; index += 1) { + const char = raw[index] as string; + if (escapable && char === '\\') { + index += 1; + continue; + } + if (char === '$' && INTERPOLATION_START.test(raw.slice(index + 1))) return true; + } + return false; +} + +/** `#{...}` — a SpEL expression the container evaluates at runtime. */ +function containsSpelExpression(value: string): boolean { + return value.includes('#{'); +} + +/** A dotted or bare identifier — the only non-literal shape read as a constant. */ +const CONSTANT_REFERENCE = /^[A-Za-z_$][A-Za-z0-9_$]*(?:\s*\.\s*[A-Za-z_$][A-Za-z0-9_$]*)*$/; + +/** + * PERMISSIVE gate — true when the text could name an address: a string literal, + * or any reference a constant resolver could plausibly fold. Says nothing about + * whether that reference actually resolves; the cascade decides that and + * records `unresolved-constant` when it does not. + * + * Used where the overload set already fixes which slot holds the destination. + */ +export function isAddressShaped(text: string): boolean { + const trimmed = text.trim(); + if (parseSpringStringLiteral(trimmed) !== null) return true; + return CONSTANT_REFERENCE.test(trimmed); +} + +/** A reference whose spelling is evidence in itself: qualified (`Topics.ORDERS`) + * or a screaming-snake constant (`ORDERS_TOPIC`). `this.x` is excluded — the + * qualifier says nothing about the member. */ +const CONFIDENT_REFERENCE = /^(?!this\s*\.)[A-Za-z_$][A-Za-z0-9_$]*\s*\.\s*[A-Za-z0-9_$.\s]+$/; +const SCREAMING_SNAKE = /^[A-Z][A-Z0-9_$]*$/; + +/** + * STRICT gate — true only when the spelling is confident evidence of an + * address, not merely compatible with one. + * + * The difference from {@link isAddressShaped} is the lowercase bare identifier. + * `convertAndSend(topic, payload)` and `convertAndSend(message, processor)` are + * the same syntax; only a human reading the names can tell which slot is the + * destination, and a name is not something this module is willing to rank as + * evidence. So a bare `topic` fails here and the publish is refused, while + * `"orders"`, `Topics.ORDERS` and `ORDERS_TOPIC` pass. + * + * Used ONLY at the arities where a trailing `MessagePostProcessor` overload + * collides with the destination-carrying one. Everywhere else the permissive + * gate applies, so this stricter rule costs nothing outside the ambiguity. + */ +export function isConfidentAddressShape(text: string): boolean { + const trimmed = text.trim(); + if (parseSpringStringLiteral(trimmed) !== null) return true; + if (!CONSTANT_REFERENCE.test(trimmed)) return false; + return CONFIDENT_REFERENCE.test(trimmed) || SCREAMING_SNAKE.test(trimmed); +} + +/** Best-effort display form for provenance text; never used as an identity. */ +function unquoteForProvenance(text: string): string { + return parseSpringStringLiteral(text) ?? text.trim(); +} + +export interface SpringPlaceholderResult { + /** True when the text contained no `${…}` at all. */ + readonly plain: boolean; + /** Key of the FIRST placeholder, in source order. Present whenever `plain` + * is false; the empty string when the placeholder named no key (`${}`). */ + readonly key?: string; + /** Default text of that placeholder, exactly as written, when it had one. + * Absent for a bare `${key}`. The empty string for `${key:}`, which is a + * default that was written and is empty. */ + readonly defaultValue?: string; +} + +/** + * Read the FIRST Spring property placeholder out of an already-unquoted value. + * + * NOTHING IS SUBSTITUTED, and that is the rule, not an omission. + * + * `${key}` cannot resolve: the value lives in a configuration file this index + * deliberately does not read into the graph (values may hold credentials — see + * `pipeline-phases/spring-config.ts`). The KEY comes back instead, so the + * caller can link the node to the `Property` nodes for that key without ever + * learning its value. + * + * `${key:default}` cannot resolve EITHER, which is a correction to this + * module's original rule. The default is written in the source, so reading it + * is legitimate and it is returned — but it is provenance, never an identity. + * A default holds only while the key is not overridden, and whether it is + * overridden is a fact about configuration VALUES, which are absent from this + * graph by design. Substituting it made `${a.topic:events}` and + * `${b.topic:events}` one node and reported a producer/consumer pair between + * two services that shared nothing but a copy-pasted fallback. The same + * reasoning applies to any placeholder-derived value: a value the configuration + * can override is not an identity. + * + * Only the first placeholder is read because there is nothing to do with the + * rest — the value is already unresolvable, and the first key is the one a + * reader would look up. A nested default (`${a:${b}}`) needs no special case + * under this rule: `a` is the key and `${b}` is the default text, both reported + * as written. + */ +export function resolveSpringPlaceholders(value: string): SpringPlaceholderResult { + const start = value.indexOf('${'); + if (start === -1) return { plain: true }; + let depth = 1; + let cursor = start + 2; + while (cursor < value.length && depth > 0) { + if (value.startsWith('${', cursor)) { + depth += 1; + cursor += 2; + continue; + } + if (value[cursor] === '}') depth -= 1; + cursor += 1; + } + // An unterminated `${` is not a placeholder this module can read. Treating + // the tail as a literal would mint an address containing `${`; treating it as + // a key at least names the thing the author was reaching for. + if (depth > 0) return { plain: false, key: value.slice(start + 2).trim() }; + const body = value.slice(start + 2, cursor - 1); + const separator = body.indexOf(':'); + // Spring splits on the FIRST colon, so `${a:b:c}` defaults to `b:c`. + if (separator === -1) return { plain: false, key: body.trim() }; + return { + plain: false, + key: body.slice(0, separator).trim(), + defaultValue: body.slice(separator + 1), + }; +} + +// ── The cascade ──────────────────────────────────────────────────────────── + +/** + * Resolve one candidate to an address, or to a named refusal. + * + * Four steps, in this order, each of which may decline: + * + * 1. literal — `"orders.v1"`, including one element of an array form. + * 2. constant — `Topics.ORDERS`, through the supplied constant resolver. + * 3. configuration — neither `${app.topic}` nor `${app.topic:orders}` + * resolves; the key, and the default text when there is + * one, are reported instead. + * 4. specification — the deferred seam; see {@link SpringDestinationResolvers}. + * + * Steps 1 and 2 both feed step 3: a literal may be a placeholder, and so may + * the value a constant folds to (`static final String TOPIC = "${app.topic}"` + * is an ordinary way to write one). Skipping step 3 after step 2 would file + * that constant's placeholder text as a resolved address — the exact false + * identity this module exists to prevent, arrived at one step later. + * + * Two classes of text are rejected BEFORE step 3, because they are not + * addresses in any configuration: a SpEL expression, which the container + * evaluates against live beans, and an unescaped string-template interpolation + * in a language that interpolates. Order matters between them and the + * placeholder rule — `"#{'${app.topics}'.split(',')}"` contains a `${` and + * would otherwise be filed under a configuration key that is not really what + * it is. + * + * WHITESPACE. An address is kept exactly as the source wrote it, `" orders "` + * included, so `" orders "` is its own node and does not join `"orders"`. That + * is a missing connection rather than a false one, which is the trade this + * module makes everywhere. The emptiness test below trims, because a + * whitespace-only address addresses nothing — the two rules disagree on + * purpose, and this is the statement of it. + */ +export function resolveSpringDestination( + candidate: SpringDestinationCandidate, + resolvers: SpringDestinationResolvers = {}, +): SpringDestinationResolution { + const specification = (): SpringDestinationResolution | null => { + const resolved = resolvers.specification?.(candidate); + if (resolved === undefined || resolved === null || resolved === '') return null; + return { kind: 'resolved', address: resolved, via: 'specification' }; + }; + + const literal = parseSpringStringLiteral(candidate.rawText); + if (literal !== null) { + // The raw spelling, not the unquoted value: unquoting has already turned + // `\$` into `$` and the escaped placeholder into the runtime template. + if ( + resolvers.interpolatesStringLiterals === true && + hasUnescapedStringInterpolation(candidate.rawText) + ) { + return specification() ?? { kind: 'unresolved', reason: 'unescaped-interpolation' }; + } + return finish(literal, 'literal', specification); + } + + const trimmed = candidate.rawText.trim(); + if (CONSTANT_REFERENCE.test(trimmed)) { + const folded = resolvers.constant?.(trimmed.replace(/\s*\.\s*/g, '.')) ?? null; + if (folded === null) + return specification() ?? { kind: 'unresolved', reason: 'unresolved-constant' }; + // A folded value has already lost its escapes, so an interpolating language + // cannot tell `"\${app.topic}"` from `"${app.topic}"` here the way the + // literal branch can. Both are unresolved either way, so the cost is a + // reason filed under `unescaped-interpolation` that might have belonged + // under `unresolved-config-key` — never a false address. + // + // A LIVE PATH, not a guard for the future. Kotlin both interpolates and + // supplies a constant fold — `languages/kotlin.ts` declares + // `extractModuleConstants` and `foldRoutePathOperands`, and + // `spring-destinations.ts` hands the fold to this cascade — so a Kotlin + // constant reaching this branch is an ordinary occurrence and the misfiled + // reason above is a cost actually paid. Fixing it means teaching the fold + // to report whether the value it returned was escaped at its declaration, + // which the shared `ModuleConstants` shape does not carry. + if (resolvers.interpolatesStringLiterals === true && /\$[{A-Za-z_]/.test(folded)) { + return specification() ?? { kind: 'unresolved', reason: 'unescaped-interpolation' }; + } + return finish(folded, 'constant', specification); + } + + return specification() ?? { kind: 'unresolved', reason: 'not-a-literal-or-constant' }; +} + +function finish( + value: string, + via: 'literal' | 'constant', + specification: () => SpringDestinationResolution | null, +): SpringDestinationResolution { + const decline = ( + reason: SpringDestinationRefusal, + extra: { configKey?: string; configDefault?: string } = {}, + ): SpringDestinationResolution => + specification() ?? { + kind: 'unresolved', + reason, + ...(extra.configKey === undefined ? {} : { configKey: extra.configKey }), + ...(extra.configDefault === undefined ? {} : { configDefault: extra.configDefault }), + }; + + // Before the placeholder rule: a SpEL expression may CONTAIN a `${…}`, and + // calling that a configuration key would name the wrong diagnosis. + if (containsSpelExpression(value)) return decline('spel-expression'); + + const placeholders = resolveSpringPlaceholders(value); + if (!placeholders.plain) { + const key = placeholders.key ?? ''; + if (key === '') return decline('empty-config-key'); + if (placeholders.defaultValue !== undefined) { + return decline('overridable-config-default', { + configKey: key, + configDefault: placeholders.defaultValue, + }); + } + return decline('unresolved-config-key', { configKey: key }); + } + + if (value.trim() === '') { + return decline(via === 'literal' ? 'empty-literal-address' : 'empty-constant-address'); + } + return { kind: 'resolved', address: value, via }; +} + +/** + * Expand one accepted argument into per-element candidates. + * + * An empty list is a refusal rather than zero silent candidates: `topics = {}` + * is a listener that names nothing, which is a finding, not an absence. + */ +function pushElements( + candidates: SpringDestinationCandidate[], + refusals: SpringDestinationRefusalRecord[], + base: Omit, +): void { + const elements = splitSpringDestinationList(base.rawText); + if (elements.length === 0) { + refusals.push({ + role: base.role, + source: base.source, + broker: base.broker, + reason: 'empty-destination-list', + rawText: base.rawText, + argIndex: base.argIndex, + ...(base.argName === undefined ? {} : { argName: base.argName }), + }); + return; + } + for (const [elementIndex, element] of elements.entries()) { + candidates.push({ ...base, rawText: element, elementIndex }); + } +} diff --git a/gitnexus/src/core/ingestion/frameworks/spring/dynamic-lookups.ts b/gitnexus/src/core/ingestion/frameworks/spring/dynamic-lookups.ts new file mode 100644 index 000000000..dfcfbb7c0 --- /dev/null +++ b/gitnexus/src/core/ingestion/frameworks/spring/dynamic-lookups.ts @@ -0,0 +1,177 @@ +import type { ParsedFile, Range, ScopeId, SymbolDefinition } from 'gitnexus-shared'; +import type { KnowledgeGraph } from '../../../graph/types.js'; +import type { DiInjectionMatch } from '../../di-extractors/index.js'; +import { SPRING_DI_INJECTION_SITES_PROPERTY } from '../../di-extractors/spring.js'; +import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; +import { + resolveCallerGraphId, + resolveDefGraphId, +} from '../../scope-resolution/graph-bridge/ids.js'; +import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js'; +import { isClassLike, lookupBindingsAt } from '../../scope-resolution/scope/walkers.js'; + +const COLLECTION_LOOKUP_METHODS = new Set(['getBeans', 'getBeansOfType']); +const SINGLE_LOOKUP_METHODS = new Set(['getBean']); + +/** + * Distinctive utility names plus conventional Spring context variable names. + * Generic locals remain recall-oriented because repositories often omit the + * third-party context type from the index; AST call/class-literal gates and + * import-aware target resolution prevent the raw-text false-positive class. + */ +const KNOWN_RECEIVERS = new Set([ + 'SpringContextUtil', + 'SpringContextHolder', + 'SpringBeanUtil', + 'ApplicationContextProvider', + 'BeanFactoryProvider', + 'ApplicationContext', + 'BeanFactory', + 'ListableBeanFactory', + 'applicationContext', + 'context', + 'ctx', + 'appContext', + 'beanFactory', +]); + +export interface SpringDynamicLookupFact { + readonly ownerScopeId: ScopeId; + readonly ownerRange: Range; + readonly receiverName: string; + readonly methodName: string; + readonly targetTypeName: string; +} + +export function springDynamicLookupCardinality( + receiverName: string, + methodName: string, +): DiInjectionMatch['cardinality'] | null { + const receiverSimpleName = receiverName.slice(receiverName.lastIndexOf('.') + 1); + if (!KNOWN_RECEIVERS.has(receiverSimpleName)) return null; + if (COLLECTION_LOOKUP_METHODS.has(methodName)) return 'collection'; + if (SINGLE_LOOKUP_METHODS.has(methodName)) return 'single'; + return null; +} + +function visibleTypeDefinitions( + fact: SpringDynamicLookupFact, + indexes: ScopeResolutionIndexes, +): readonly SymbolDefinition[] { + const simpleName = fact.targetTypeName.slice(fact.targetTypeName.lastIndexOf('.') + 1); + let scopeId: ScopeId | null = fact.ownerScopeId; + + while (scopeId !== null) { + const visible = lookupBindingsAt(scopeId, simpleName, indexes) + .map(({ def }) => def) + .filter((def) => isClassLike(def.type)) + .filter( + (def) => !fact.targetTypeName.includes('.') || def.qualifiedName === fact.targetTypeName, + ); + if (visible.length > 0) { + const unique = new Map(visible.map((def) => [def.nodeId, def])); + return [...unique.values()]; + } + scopeId = indexes.scopeTree.getScope(scopeId)?.parent ?? null; + } + + return []; +} + +function resolveTargetTypeName( + graph: KnowledgeGraph, + fact: SpringDynamicLookupFact, + callerLanguage: string | undefined, + nodeLookup: GraphNodeLookup, + indexes: ScopeResolutionIndexes, +): string | undefined { + const graphIds = new Set(); + for (const definition of visibleTypeDefinitions(fact, indexes)) { + const graphId = resolveDefGraphId(definition.filePath, definition, nodeLookup); + if (graphId === undefined) continue; + const node = graph.getNode(graphId); + if ( + (node?.label === 'Class' || + node?.label === 'Interface' || + node?.label === 'Record' || + node?.label === 'Enum') && + node.properties.language === callerLanguage + ) { + graphIds.add(graphId); + } + } + if (graphIds.size !== 1) return undefined; + + const targetId = graphIds.values().next().value; + if (targetId === undefined) return undefined; + const target = graph.getNode(targetId); + if (target === undefined) return undefined; + const qualifiedName = target.properties.qualifiedName; + return typeof qualifiedName === 'string' ? qualifiedName : target.properties.name; +} + +export interface SpringDynamicLookupMetadataAdapter { + getFacts(filePath: string): readonly SpringDynamicLookupFact[]; +} + +/** + * Attach AST-captured programmatic Spring lookups to the framework-neutral DI + * resolver. Java/Kotlin own syntax capture; this shared JVM/Spring seam owns + * import-aware type binding and metadata attachment. + */ +export function createSpringDynamicLookupMetadataAttacher( + adapter: SpringDynamicLookupMetadataAdapter, +) { + return ( + graph: KnowledgeGraph, + parsedFiles: readonly ParsedFile[], + nodeLookup: GraphNodeLookup, + indexes: ScopeResolutionIndexes, + ): void => { + for (const parsed of parsedFiles) { + for (const fact of adapter.getFacts(parsed.filePath)) { + const cardinality = springDynamicLookupCardinality(fact.receiverName, fact.methodName); + if (cardinality === null) continue; + + const callerId = resolveCallerGraphId(fact.ownerScopeId, indexes, nodeLookup, { + startLine: fact.ownerRange.startLine, + startCol: fact.ownerRange.startCol, + }); + if (callerId === undefined) continue; + const caller = graph.getNode(callerId); + if ( + caller === undefined || + (caller.label !== 'Function' && + caller.label !== 'Method' && + caller.label !== 'Constructor') + ) { + continue; + } + + const targetTypeName = resolveTargetTypeName( + graph, + fact, + caller.properties.language, + nodeLookup, + indexes, + ); + if (targetTypeName === undefined) continue; + + const match: DiInjectionMatch = { + targetTypeName, + cardinality, + edgeSource: 'site', + reason: `Spring dynamic lookup: ${fact.receiverName}.${fact.methodName}(${fact.targetTypeName})`, + }; + // Singular lookups intentionally use the shared DI selection policy: + // a unique/@Primary candidate wins; unresolved multiplicity is an + // explicit 0.5-confidence fan-out rather than a guessed runtime winner. + const existing = caller.properties[SPRING_DI_INJECTION_SITES_PROPERTY]; + caller.properties[SPRING_DI_INJECTION_SITES_PROPERTY] = [ + ...(Array.isArray(existing) ? existing : []), + match, + ]; + } + } + }; +} diff --git a/gitnexus/src/core/ingestion/frameworks/spring/message-producers.ts b/gitnexus/src/core/ingestion/frameworks/spring/message-producers.ts new file mode 100644 index 000000000..4a4a731dc --- /dev/null +++ b/gitnexus/src/core/ingestion/frameworks/spring/message-producers.ts @@ -0,0 +1,140 @@ +import type { Range, ScopeId } from 'gitnexus-shared'; +import type { SpringArgumentFact } from './argument-facts.js'; + +/** + * Outbound side of Spring messaging: the template calls that publish to a + * broker destination, mirroring the inbound `@KafkaListener` / `@RabbitListener` + * family already recognized in `non-http-handlers.ts`. + * + * Recognition is purely syntactic and happens while the language's own scope + * query already has the call node in hand. The receiver's declared type is NOT + * consulted: at capture time the field may be inherited, injected from another + * file, or typed through an import that is not finalized yet. Matching on the + * receiver's simple name instead keeps the capture cheap and resolver-free; a + * later phase that owns type information can refine or discard a fact. + */ +export type SpringMessageProducerTemplate = 'kafka' | 'rabbit' | 'jms' | 'stream-bridge'; + +interface ProducerSignature { + readonly template: SpringMessageProducerTemplate; + /** + * Simple type name of the template bean, matched case-insensitively as a + * SUBSTRING of the receiver's folded simple name. The classifier below states + * which decorations that accepts, and what it does when one receiver name + * contains the type names of two different templates. + */ + readonly typeName: string; + readonly methodName: string; +} + +const PRODUCER_SIGNATURES: readonly ProducerSignature[] = [ + { template: 'kafka', typeName: 'KafkaTemplate', methodName: 'send' }, + { template: 'rabbit', typeName: 'RabbitTemplate', methodName: 'convertAndSend' }, + { template: 'jms', typeName: 'JmsTemplate', methodName: 'convertAndSend' }, + { template: 'stream-bridge', typeName: 'StreamBridge', methodName: 'send' }, +]; + +const PRODUCER_METHOD_NAMES: ReadonlySet = new Set( + PRODUCER_SIGNATURES.map((signature) => signature.methodName), +); + +/** A receiver we can attribute; `templates["k"]` or `getTemplate()` cannot be. */ +const PLAIN_IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/; + +/** + * Fold a receiver's simple name to the form the type-name match runs against. + * + * `_` and `$` are word separators in the spellings this has to accept, not part + * of the words: `KAFKA_TEMPLATE` and `kafka_template` are the same bean name as + * `kafkaTemplate`, written to the constant and snake conventions. Digits stay, + * because they are part of a name (`kafkaTemplate2`), never a separator. + */ +function foldReceiverName(receiverSimpleName: string): string { + return receiverSimpleName.replace(/[_$]/g, '').toLowerCase(); +} + +/** + * Cheap pre-filter usable before any receiver text is materialized. Both + * languages visit every member call, so the common case must cost one set + * lookup on the method name. + */ +export function isSpringMessageProducerMethod(methodName: string): boolean { + return PRODUCER_METHOD_NAMES.has(methodName); +} + +/** + * Classify a `receiver.method(...)` call as a messaging producer, or `null`. + * + * `receiverName` is the receiver expression as written; only its last + * dot-separated segment participates, so `this.kafkaTemplate` and + * `outer.inner.kafkaTemplate` match while `templates.get("k")` does not. + * + * The PLAIN_IDENTIFIER gate runs BEFORE the fold and is load-bearing, because + * the last-dot split is textual: in `config.get("a.kafkaTemplate")` it yields + * `kafkaTemplate")`, which folds to something a name match would accept. Only + * an identifier survives the gate, which is also what rejects `templates["k"]`, + * `getTemplate()`, and a receiver whose dot is separated by a comment. + * + * The folded segment then matches case-insensitively when it CONTAINS the + * template type name, so every convention a template bean is really declared + * with is recognized — decorated by prefix (`orderKafkaTemplate`), by suffix + * (`kafkaTemplateDlq`, `kafkaTemplateV2`, `rabbitTemplate1`), or written as a + * constant (`KAFKA_TEMPLATE`, `STREAM_BRIDGE`). A suffix-only rule accepted + * one of those and silently dropped the rest, which are exactly the publishes + * this capture exists to find. A receiver named only `template` still does not + * match: without type information that would attribute any `send` in the + * repository to Kafka. + * + * The bare type name (`KafkaTemplate.send(...)`) contains itself and so is + * accepted. That is left as it is: the match is by NAME, a name equal to the + * type is the strongest evidence the rule has, and a later phase that owns type + * information can discard a static-looking receiver. + * + * A substring rule also lets ONE receiver satisfy TWO signatures, which a + * suffix rule could not: `KafkaTemplate` and `StreamBridge` both publish + * through `send`, and `RabbitTemplate` and `JmsTemplate` both through + * `convertAndSend`, so `streamBridgeKafkaTemplate.send(...)` matches two + * templates at once. Such a receiver yields NO fact. Nothing here can break the + * tie honestly: the receiver's TYPE is deliberately not resolved, and the name + * is not ranked evidence — neither the longest match, nor the last one, nor the + * order of this list says whether that bean is a KafkaTemplate fronted by a + * stream binding or a StreamBridge named after the broker behind it. Returning + * the first match published an arbitrary choice as a definite broker + * attribution, the one outcome a consumer cannot tell from a fact. Silence + * costs a rare publish and stays recoverable by a phase that owns types. + */ +export function springMessageProducerTemplateOf( + receiverName: string, + methodName: string, +): SpringMessageProducerTemplate | null { + if (!isSpringMessageProducerMethod(methodName)) return null; + const receiverSimpleName = receiverName.slice(receiverName.lastIndexOf('.') + 1).trim(); + if (!PLAIN_IDENTIFIER.test(receiverSimpleName)) return null; + const folded = foldReceiverName(receiverSimpleName); + let matched: SpringMessageProducerTemplate | null = null; + for (const signature of PRODUCER_SIGNATURES) { + if (signature.methodName !== methodName) continue; + if (!folded.includes(signature.typeName.toLowerCase())) continue; + // A second match makes the receiver ambiguous; see above for why it is not + // resolved by preferring one of them. + if (matched !== null) return null; + matched = signature.template; + } + return matched; +} + +export interface SpringMessageProducerFact { + /** Callable that performs the publish; the enclosing method or function. */ + readonly ownerScopeId: ScopeId; + readonly ownerRange: Range; + readonly template: SpringMessageProducerTemplate; + /** Receiver expression as written, for example `this.orderKafkaTemplate`. */ + readonly receiverName: string; + readonly methodName: string; + /** + * Call arguments in source order, or absent when the call site has no + * argument list at all (a Kotlin trailing-lambda call). An empty array means + * an empty argument list was written — a different fact from no list. + */ + readonly args?: readonly SpringArgumentFact[]; +} diff --git a/gitnexus/src/core/ingestion/frameworks/spring/non-http-handlers.ts b/gitnexus/src/core/ingestion/frameworks/spring/non-http-handlers.ts new file mode 100644 index 000000000..ae43f3eac --- /dev/null +++ b/gitnexus/src/core/ingestion/frameworks/spring/non-http-handlers.ts @@ -0,0 +1,248 @@ +import type { GraphNode, ParsedFile, Range, ScopeId } from 'gitnexus-shared'; +import type { KnowledgeGraph } from '../../../graph/types.js'; +import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; +import { resolveCallerGraphId } from '../../scope-resolution/graph-bridge/ids.js'; +import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js'; +import type { SpringArgumentFact } from './argument-facts.js'; +import { createSpringAnnotationNameResolver } from './bean-candidates.js'; +import { SPRING_BEAN_ANNOTATION } from './bean-factories.js'; + +export const SPRING_NON_HTTP_HANDLER_ENTRY_POINT_MULTIPLIER = 3.0; + +export type SpringNonHttpHandlerKind = 'scheduled' | 'event' | 'message' | 'xxl-job'; + +export interface SpringNonHttpHandlerAnnotationFact { + readonly name: string; + /** Kotlin use-site targets describe generated/property elements, not the callable. */ + readonly useSiteTarget?: string; + /** + * Annotation arguments in source order. An empty array always means an empty + * list was written (`@Scheduled()`), which is a different fact from absence — + * but absence has TWO causes, and only one of them is a statement about the + * source. Either the annotation was written without an argument list + * (`@Scheduled`), or arguments were never read for this callable. + * + * They are read only for a callable that carries a handler annotation. Java + * produces facts for no other callable, so there absence does mean "no list + * was written". Kotlin also produces a fact for a merely annotated function — + * it captures those without a name prefilter so an import alias cannot hide a + * handler — and on those facts arguments are absent however the annotation + * was written. + * + * The values keep their source spelling, with one deliberate exception: + * `normalizeSpringFactText` trims them and collapses whitespace around the + * dots of a multi-line expression, so `Destinations.ORDERS` and the same + * reference wrapped across lines produce equal facts. Without that, source + * formatting — including the enclosing block's indentation, which is not a + * property of the expression at all — would leak into the data and make two + * spellings of one destination compare unequal downstream. + * + * Nothing else is touched. `@KafkaListener(topics = ...)` and + * `@RabbitListener(queues = ...)` name the destination differently, and a + * destination may be a literal, a constant reference, or a `${...}` + * placeholder; resolving any of those belongs to a later phase. + */ + readonly args?: readonly SpringArgumentFact[]; +} + +export interface SpringNonHttpHandlerFact< + Annotation extends SpringNonHttpHandlerAnnotationFact = SpringNonHttpHandlerAnnotationFact, +> { + readonly ownerScopeId: ScopeId; + readonly ownerFilePath?: string; + /** Exact syntax range used only as a fail-closed bridge for collapsed language scopes. */ + readonly ownerRange?: Range; + readonly annotations: readonly Annotation[]; +} + +export interface SpringNonHttpHandlerAdapter< + Annotation extends SpringNonHttpHandlerAnnotationFact, +> { + getFacts(filePath: string): readonly SpringNonHttpHandlerFact[]; + isPackageVisibilityIncomplete(filePath: string): boolean; +} + +const SPRING_SERVICE_ACTIVATOR_ANNOTATION = + 'org.springframework.integration.annotation.ServiceActivator'; + +const HANDLER_ANNOTATIONS = new Map([ + ['org.springframework.scheduling.annotation.Scheduled', 'scheduled'], + ['org.springframework.scheduling.annotation.Schedules', 'scheduled'], + ['org.springframework.context.event.EventListener', 'event'], + ['org.springframework.transaction.event.TransactionalEventListener', 'event'], + ['org.springframework.modulith.events.ApplicationModuleListener', 'event'], + ['org.springframework.kafka.annotation.KafkaListener', 'message'], + ['org.springframework.kafka.annotation.KafkaListeners', 'message'], + ['org.springframework.amqp.rabbit.annotation.RabbitListener', 'message'], + ['org.springframework.amqp.rabbit.annotation.RabbitListeners', 'message'], + ['org.springframework.jms.annotation.JmsListener', 'message'], + ['org.springframework.jms.annotation.JmsListeners', 'message'], + ['org.springframework.pulsar.annotation.PulsarListener', 'message'], + ['org.springframework.pulsar.annotation.PulsarListeners', 'message'], + ['io.awspring.cloud.sqs.annotation.SqsListener', 'message'], + ['io.awspring.cloud.messaging.listener.annotation.SqsListener', 'message'], + ['org.springframework.cloud.aws.messaging.listener.annotation.SqsListener', 'message'], + ['org.springframework.cloud.stream.annotation.StreamListener', 'message'], + [SPRING_SERVICE_ACTIVATOR_ANNOTATION, 'message'], + ['org.springframework.messaging.handler.annotation.MessageMapping', 'message'], + ['org.springframework.messaging.simp.annotation.SubscribeMapping', 'message'], + ['com.xxl.job.core.handler.annotation.XxlJob', 'xxl-job'], +]); + +const RECOGNIZED_HANDLER_ANNOTATIONS = new Set(HANDLER_ANNOTATIONS.keys()); +const RESOLVABLE_NON_HTTP_ANNOTATIONS = new Set([ + ...RECOGNIZED_HANDLER_ANNOTATIONS, + SPRING_BEAN_ANNOTATION, +]); + +function simpleName(name: string): string { + const separator = name.lastIndexOf('.'); + return separator === -1 ? name : name.slice(separator + 1); +} + +const CAPTURE_RELEVANT_SIMPLE_NAMES = new Set([...RECOGNIZED_HANDLER_ANNOTATIONS].map(simpleName)); + +export function hasSpringNonHttpHandlerRelevantAnnotation( + annotations: readonly Pick[], +): boolean { + return annotations.some((annotation) => + CAPTURE_RELEVANT_SIMPLE_NAMES.has(simpleName(annotation.name)), + ); +} + +function exactCallableOwnersByRange(graph: KnowledgeGraph): ReadonlyMap { + const owners = new Map(); + for (const node of graph.iterNodes()) { + if ( + (node.label !== 'Method' && node.label !== 'Function') || + typeof node.properties.filePath !== 'string' + ) { + continue; + } + const key = `${node.properties.filePath}\0${node.properties.startLine}\0${node.properties.endLine}`; + owners.set(key, owners.has(key) ? null : node); + } + return owners; +} + +function ownerGraphNode( + fact: SpringNonHttpHandlerFact, + indexes: ScopeResolutionIndexes, + nodeLookup: GraphNodeLookup, + graph: KnowledgeGraph, + getExactOwnerByRange: () => ReadonlyMap, +): GraphNode | undefined { + const ownerId = resolveCallerGraphId(fact.ownerScopeId, indexes, nodeLookup); + if (ownerId !== undefined) { + const owner = graph.getNode(ownerId); + if (owner?.label === 'Method' || owner?.label === 'Function') return owner; + } + if (fact.ownerFilePath !== undefined && fact.ownerRange !== undefined) { + const fallback = getExactOwnerByRange().get( + `${fact.ownerFilePath}\0${fact.ownerRange.startLine - 1}\0${fact.ownerRange.endLine - 1}`, + ); + if (fallback !== null && fallback !== undefined) return fallback; + } + return undefined; +} + +function handlerReason(kinds: ReadonlySet): string { + if (kinds.size !== 1) { + return kinds.has('xxl-job') ? 'managed-non-http-handler' : 'spring-non-http-handler'; + } + const kind = kinds.values().next().value; + if (kind === 'xxl-job') return 'xxl-job-handler'; + return `spring-${kind}-handler`; +} + +/** + * Resolve callable annotations after imports and package visibility finalize, + * then promote confirmed framework-managed handlers into process entry points. + */ +export function createSpringNonHttpHandlerMetadataAttacher< + Annotation extends SpringNonHttpHandlerAnnotationFact, +>(adapter: SpringNonHttpHandlerAdapter) { + return ( + graph: KnowledgeGraph, + parsedFiles: readonly ParsedFile[], + nodeLookup: GraphNodeLookup, + indexes: ScopeResolutionIndexes, + ): void => { + const factsByFile = new Map[]>(); + for (const parsed of parsedFiles) { + const facts = adapter.getFacts(parsed.filePath); + if (facts.length > 0) factsByFile.set(parsed.filePath, facts); + } + if (factsByFile.size === 0) return; + + const resolveAnnotation = createSpringAnnotationNameResolver(indexes); + let exactOwnerByRange: ReadonlyMap | undefined; + const getExactOwnerByRange = (): ReadonlyMap => + (exactOwnerByRange ??= exactCallableOwnersByRange(graph)); + let classIdByMethod: ReadonlyMap | undefined; + const ownerClassLabel = (methodId: string): GraphNode['label'] | undefined => { + if (classIdByMethod === undefined) { + const owners = new Map(); + for (const relationship of graph.iterRelationshipsByType('HAS_METHOD')) { + owners.set(relationship.targetId, relationship.sourceId); + } + classIdByMethod = owners; + } + const classId = classIdByMethod.get(methodId); + return classId === undefined ? undefined : graph.getNode(classId)?.label; + }; + + for (const parsed of parsedFiles) { + const facts = factsByFile.get(parsed.filePath); + if (facts === undefined) continue; + const incomplete = adapter.isPackageVisibilityIncomplete(parsed.filePath); + const resolvedAnnotations = new Map(); + for (const fact of facts) { + const ownerScope = indexes.scopeTree.getScope(fact.ownerScopeId); + const resolvedFactAnnotations = new Set(); + for (const annotation of fact.annotations) { + if (annotation.useSiteTarget !== undefined) continue; + const enclosingScope = ownerScope?.parent ?? null; + const cacheKey = `${enclosingScope ?? ''}\0${annotation.name}`; + let resolved = resolvedAnnotations.get(cacheKey); + if (!resolvedAnnotations.has(cacheKey)) { + resolved = resolveAnnotation( + annotation.name, + parsed, + enclosingScope, + RESOLVABLE_NON_HTTP_ANNOTATIONS, + incomplete, + ); + resolvedAnnotations.set(cacheKey, resolved); + } + if (resolved !== undefined) resolvedFactAnnotations.add(resolved); + } + + const beanFactoryMethod = resolvedFactAnnotations.has(SPRING_BEAN_ANNOTATION); + const kinds = new Set(); + for (const resolved of resolvedFactAnnotations) { + if (beanFactoryMethod && resolved === SPRING_SERVICE_ACTIVATOR_ANNOTATION) continue; + const kind = HANDLER_ANNOTATIONS.get(resolved); + if (kind !== undefined) kinds.add(kind); + } + if (kinds.size === 0) continue; + + const owner = ownerGraphNode(fact, indexes, nodeLookup, graph, getExactOwnerByRange); + if (owner === undefined || ownerClassLabel(owner.id) === 'Interface') continue; + + const currentMultiplier = owner.properties.astFrameworkMultiplier ?? 1.0; + owner.properties.astFrameworkMultiplier = Math.max( + currentMultiplier, + SPRING_NON_HTTP_HANDLER_ENTRY_POINT_MULTIPLIER, + ); + if ( + currentMultiplier < SPRING_NON_HTTP_HANDLER_ENTRY_POINT_MULTIPLIER || + (currentMultiplier === SPRING_NON_HTTP_HANDLER_ENTRY_POINT_MULTIPLIER && + owner.properties.astFrameworkReason === undefined) + ) { + owner.properties.astFrameworkReason = handlerReason(kinds); + } + } + } + }; +} diff --git a/gitnexus/src/core/ingestion/frameworks/spring/vendor-prefixes.ts b/gitnexus/src/core/ingestion/frameworks/spring/vendor-prefixes.ts new file mode 100644 index 000000000..8134104b4 --- /dev/null +++ b/gitnexus/src/core/ingestion/frameworks/spring/vendor-prefixes.ts @@ -0,0 +1,27 @@ +const DEFAULT_SPRING_VENDOR_PREFIXES = 'Win'; + +let cachedRawValue: string | undefined; +let cachedPrefixes: ReadonlySet | undefined; + +/** Return the configured vendor prefixes as a canonical, duplicate-free set. */ +export function springVendorPrefixes(): ReadonlySet { + const rawValue = process.env.GITNEXUS_SPRING_VENDOR_PREFIXES ?? DEFAULT_SPRING_VENDOR_PREFIXES; + if (cachedPrefixes && cachedRawValue === rawValue) return cachedPrefixes; + + cachedRawValue = rawValue; + cachedPrefixes = new Set( + rawValue + .split(',') + .map((prefix) => prefix.trim()) + .filter(Boolean), + ); + return cachedPrefixes; +} + +/** + * Stable metadata value for the route semantics controlled by the prefix list. + * Sorting makes equivalent lists independent of declaration order. + */ +export function springVendorPrefixesKey(): string { + return JSON.stringify([...springVendorPrefixes()].sort()); +} diff --git a/gitnexus/src/core/ingestion/import-resolvers/configs/csharp.ts b/gitnexus/src/core/ingestion/import-resolvers/configs/csharp.ts index d99dbd35b..24e9e6cfa 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/configs/csharp.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/configs/csharp.ts @@ -31,8 +31,10 @@ export const csharpNamespaceStrategy: ImportResolverStrategy = (rawImportPath, _ const resolvedFiles = resolveCSharpImportInternal( rawImportPath, csharpConfigs, - ctx.normalizedFileList, - ctx.allFileList, + // The Set, not `ctx.normalizedFileList`/`ctx.allFileList`: the resolver + // derives both from it through the same per-pass memo the ctx's own arrays + // come from, so this is the identical pair by a shorter route. + ctx.allFilePaths, ctx.index, evidence, ); diff --git a/gitnexus/src/core/ingestion/import-resolvers/configs/swift.ts b/gitnexus/src/core/ingestion/import-resolvers/configs/swift.ts index 8235edf73..05e4b13be 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/configs/swift.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/configs/swift.ts @@ -39,6 +39,19 @@ interface SwiftTargetIndex { * stable reference and the index is built once — not once per import. A * fresh run produces a fresh array → a fresh index, so cross-run staleness * is impossible. + * + * DELIBERATELY NOT ON `import-resolvers/per-file-set.ts` (#2909 sweep): this is + * a TWO-input memo keyed on ONE of them. The index is a function of both + * `ctx` (`allFileList` + the index-aligned `normalizedFileList`) and `targets`, + * but the key is only `ctx.allFileList`, and `perFileSet`'s `build: (key) => T` + * hands the builder nothing but the key. It is sound here only because of an + * invariant OUTSIDE the memo — `targets` is `ctx.configs.swiftPackageConfig + * .targets`, so it shares `ctx`'s lifetime and cannot vary while + * `ctx.allFileList` is fixed — and `perFileSet` has no way to express "and this + * other input is pinned by the same lifetime". Re-keying on `ctx` to make + * `targets` derivable from the key would change what the cache is keyed on and + * force an unreachable null-config arm into the builder, so it is a behaviour + * change rather than a consolidation. Leave it hand-rolled. */ const SWIFT_TARGET_INDEX_CACHE = new WeakMap(); diff --git a/gitnexus/src/core/ingestion/import-resolvers/csharp.ts b/gitnexus/src/core/ingestion/import-resolvers/csharp.ts index 2ce21274f..e8d6fdf7a 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/csharp.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/csharp.ts @@ -5,11 +5,260 @@ * This file contains shared helpers for namespace-based resolution. */ +import { perFileSet } from './per-file-set.js'; +import { getWorkspaceFileIndex } from './workspace-file-index.js'; import type { SuffixIndex } from './utils.js'; import { suffixResolve } from './utils.js'; import type { CSharpProjectConfig, CSharpNamespaceEvidence } from '../language-config.js'; import { csharpSuffixFallbackAllowed } from '../csharp-namespace-gate.js'; +/** + * Directory index backing the namespace-directory fallback below (step 3). + * + * That fallback used to be a full `normalizedFileList` pass per import, per + * matching csproj config — Θ(files), measured at ~1.08 ms per import over + * 50 000 `.cs` files (#2902). #2878 removed the per-import array REBUILD but + * not the scan itself. + * + * The scan's predicate depends only on the file's DIRECTORY, so it can be + * answered from an index built once per file list. Writing `D` for the + * normalized directory of a `.cs` file and `dirPrefix` for the query: + * + * let H = D + '/', P = dirPrefix + '/' + * match ⟺ H.endsWith(P) + * + * Derivation: + * - the scan keeps a file only when nothing after the matched occurrence holds + * a slash, so the occurrence's trailing '/' must be the file's LAST slash — + * i.e. `H` ends with `P`; + * - it used `indexOf`, the FIRST occurrence, so `a/Models/b/Models/x.cs` did + * NOT answer `Models`. That half was removed in #2881: it was an artifact of + * how the pre-index scan was written, not a rule about C# namespaces, and it + * dropped every repository that nests a directory name inside itself. The + * same removal landed in `package-dir-index.ts` and in step 2 below, which + * have to move together — see the note at step 3. + * - the needle ends with '/', so every occurrence of it lies wholly inside + * `D + '/'` and never reaches into the file name — which is what lets the + * whole test be evaluated on `D` alone; + * - and then the '/' cancels. `(D + '/').endsWith(P + '/')` IS `D.endsWith(P)`: + * the appended character only ever matches itself, so it decides nothing and + * the comparison of everything before it is unchanged. The predicate the code + * actually runs is therefore + * + * match ⟺ D.endsWith(dirPrefix) + * + * with no concatenation on either side. Verified rather than argued: over + * every ordered pair of strings up to length 5 over `{a, b, '/'}` including + * the empty string — 132 496 pairs — the two forms disagreed 0 times. + * + * NOT the same query as `package-dir-index.ts`, and the difference is exactly + * one character on each side: that module tests `'/'+D+'/'` against + * `'/'+pkgPath+'/'`, whose leading slash anchors the match to a segment + * boundary. This scan has no leading slash, so `dirPrefix = 'Models'` also + * matches `src/SubModels/` and `dirPrefix = 'src/Models'` also matches + * `vendor/mysrc/Models/`. Those hits are reachable (step 2 below answers only + * the segment-aligned ones, and step 3 runs precisely when step 2 found + * nothing), so the looser predicate is preserved verbatim rather than + * "cleaned up" into a reuse of `filesDirectlyInPkgDir` — see + * `test/unit/import-resolvers/csharp-csproj-parity.test.ts`. + * + * That one character is also why the cancellation above empties this predicate + * out but not that one: the decoration is one term per side here (`D + '/'`) and + * two there (`'/' + D + '/'`), and only the TRAILING '/' cancels. Here nothing + * is left to concatenate; there the leading segment anchor has to stay. + * + * Candidates are narrowed by the directory's LAST segment, the same + * O(directories) bucket `package-dir-index.ts` uses instead of an + * O(files × depth) suffix map (#2649). + */ +interface CsharpNamespaceDirIndex { + /** Last path segment of a directory → every `.cs` directory ending in it. */ + readonly dirsByLastSegment: ReadonlyMap; + /** + * Directory → positions in `WorkspaceFileIndex.normalized` of the `.cs` files + * directly inside it, ascending. + * + * Positions rather than paths: the emitted value is the RAW path, and the two + * arrays are parallel by construction — `normalized` is `all.map(slash)` — so + * a position is the one key that reads correctly in either. Both arrays come + * from the same `getWorkspaceFileIndex(allFilePaths)` object as this index + * itself, so the pairing cannot drift; it used to be a precondition on the + * caller, who passed the two arrays independently. + */ + readonly positionsByDir: ReadonlyMap; + /** + * Directories with no slash of their own — the entire answer to an empty + * `dirPrefix`, which is the one query no last-segment bucket expresses. + */ + readonly singleSegmentDirs: readonly string[]; +} + +/** + * Memoized on the file SET's identity, the same key every other per-file-set + * index in this pipeline uses: the orchestrator builds one Set per pass and + * threads it through every import, so this build runs once. + * + * It used to key on the `normalizedFileList` ARRAY, which was a second key + * shape and — more to the point — one no guard could instrument. Copying an + * array mints a fresh `WeakMap` key while traversing the SET zero extra times, + * so a `[...normalized]` copy at the adapter boundary rebuilt this index once + * per `using` while every scan-counting guard stayed green and only the timing + * bench noticed (#2911 review). Taking the array from + * `getWorkspaceFileIndex(allFilePaths)` inside the builder retires that shape: + * the only way to defeat the memo now is to copy the Set, which is exactly what + * `CountingSet` counts. + * + * It also retires a precondition. The cached positions index `normalized` while + * the emitted value is read from `all`; both now come from the same + * `getWorkspaceFileIndex` object, so the caller can no longer pair a position + * list against a differently-ordered array. + */ +const getCsharpNamespaceDirIndex = perFileSet( + (allFilePaths: ReadonlySet): CsharpNamespaceDirIndex => { + const { normalized: normalizedFileList } = getWorkspaceFileIndex(allFilePaths); + const dirsByLastSegment = new Map(); + const positionsByDir = new Map(); + const singleSegmentDirs: string[] = []; + + for (let i = 0; i < normalizedFileList.length; i++) { + const normalized = normalizedFileList[i]; + if (!normalized.endsWith('.cs')) continue; + const lastSlash = normalized.lastIndexOf('/'); + // A file with no directory can never match: the needle always ends with + // '/', so `indexOf` on a slash-free path is always -1. + if (lastSlash < 0) continue; + + const dir = normalized.slice(0, lastSlash); + let positions = positionsByDir.get(dir); + if (positions === undefined) { + positions = []; + positionsByDir.set(dir, positions); + const lastSegment = dir.slice(dir.lastIndexOf('/') + 1); + if (lastSegment === dir) singleSegmentDirs.push(dir); + let dirs = dirsByLastSegment.get(lastSegment); + if (dirs === undefined) { + dirs = []; + dirsByLastSegment.set(lastSegment, dirs); + } + dirs.push(dir); + } + positions.push(i); + } + + return { dirsByLastSegment, positionsByDir, singleSegmentDirs }; + }, +); + +/** + * Every directory that could satisfy `dirPrefix`, as a superset — the exact + * test runs in `matchingDirPositions`. + * + * When `dirPrefix` contains a '/', its own slash forces a segment boundary in + * any matching directory: `H` ending with `…//` means `D` ends with + * `/`, so `D`'s last segment IS `lastSeg` and the exact bucket is + * complete. Without a '/', `D`'s last segment only has to END with `dirPrefix` + * (`SubModels` for `Models`), which no single bucket holds, so the last-segment + * KEYS are swept. That is the one term here that is not O(matches), and it is + * O(distinct last segments), not O(directories): C# repos reuse `Models`, + * `Services`, `Controllers` under every project, so the sweep collapses on the + * layouts that actually occur. Measured at 200 000 `.cs` files, 25 000 + * directories: 456 µs per import when every directory name is unique, 7.9 µs + * on a `SrcN/Models` layout. Closing the unique-name case needs a character- + * suffix map over the segments, which is the O(files × depth) memory shape + * `package-dir-index.ts` cites #2649 to avoid — a design change, not a tune. + * + * An empty `dirPrefix` would sweep every key and keep every directory, so it is + * answered from `singleSegmentDirs` instead: its needle is a bare '/', which + * only a slash-free directory can carry as its LAST slash. + */ +function* candidateDirs(index: CsharpNamespaceDirIndex, dirPrefix: string): Generator { + if (dirPrefix === '') { + yield* index.singleSegmentDirs; + return; + } + const lastSlash = dirPrefix.lastIndexOf('/'); + if (lastSlash >= 0) { + const bucket = index.dirsByLastSegment.get(dirPrefix.slice(lastSlash + 1)); + if (bucket !== undefined) yield* bucket; + return; + } + for (const [lastSegment, dirs] of index.dirsByLastSegment) { + if (!lastSegment.endsWith(dirPrefix)) continue; + yield* dirs; + } +} + +/** Positions of the `.cs` files in each directory matching `dirPrefix`. */ +function* matchingDirPositions( + index: CsharpNamespaceDirIndex, + dirPrefix: string, +): Generator { + for (const dir of candidateDirs(index, dirPrefix)) { + // `(dir + '/').endsWith(dirPrefix + '/')` IS `dir.endsWith(dirPrefix)` — the + // appended '/' only ever matches itself, so it decides nothing and BOTH + // concatenations go. Exhaustively verified, not assumed: 0 disagreements + // over every ordered pair of strings up to length 5 over `{a, b, '/'}` + // including '' (132 496 pairs). Measured 64.9 ns -> 18.4 ns per candidate + // (Node 22.18); the `dir + '/'` was paid once per candidate, on every sweep + // of the last-segment keys. + // + // Still deliberately UNANCHORED (no leading '/'), so `src/SubModels` keeps + // answering `Models` — see the derivation above. That is also exactly why + // the reduction empties this predicate out while `package-dir-index.ts` + // keeps its concatenations: one decorating term per side here, two there, + // and only the trailing one cancels. + // + // `endsWith` subsumes the length guard the `indexOf` form needed: a shorter + // `dir` is simply false, where `indexOf` returned -1 and + // `haystack.length - needle.length` could also be -1 and report a bogus + // match. + // + // Do NOT "finish the job" with the two-argument overload. `endsWith(search, + // endPosition)` measured 8.8-11.8 ns against 9.5-14.9 ns for the + // one-argument form across seven call-site shapes (Node 22.18) — a wash — + // and `dir.endsWith(dirPrefix, dir.length)` is character-for-character this + // same test anyway. There is nothing left here to win. + if (!dir.endsWith(dirPrefix)) continue; + const positions = index.positionsByDir.get(dir); + if (positions !== undefined) yield positions; + } +} + +/** + * Append every `.cs` file directly inside a directory matching `dirPrefix`, in + * `normalizedFileList` order — the order the single-pass scan emitted, which + * this function's callers return as the whole edge target list. + */ +function pushFilesDirectlyInNamespaceDir( + index: CsharpNamespaceDirIndex, + dirPrefix: string, + allFileList: readonly string[], + results: string[], +): void { + // One matching directory is the overwhelmingly common case, and its positions + // are already ascending, so the first bucket is held by reference. A second + // one promotes it to a real accumulator that is appended to from then on — + // never re-spread per directory, which would cost O(files × dirs²) copies in + // a monorepo carrying the same namespace directory under many projects. + let first: readonly number[] | null = null; + let merged: number[] | null = null; + for (const positions of matchingDirPositions(index, dirPrefix)) { + if (first === null) { + first = positions; + continue; + } + if (merged === null) merged = [...first]; + for (const position of positions) merged.push(position); + } + if (first === null) return; + if (merged === null) { + for (const position of first) results.push(allFileList[position]); + return; + } + merged.sort((a, b) => a - b); + for (const position of merged) results.push(allFileList[position]); +} + /** * Resolve a C# using-directive import path to matching .cs files (low-level helper). * Tries single-file match first, then directory match for namespace imports. @@ -17,15 +266,23 @@ import { csharpSuffixFallbackAllowed } from '../csharp-namespace-gate.js'; * The final unanchored suffix fallback is gated on `evidence` so BCL usings * (e.g. `System.Threading.Tasks`) can't match a coincidentally-named local * file (#1881). When `evidence` is omitted the fallback stays permissive. + * + * Takes the file SET, not the two materialized lists it used to take: both are + * derived here from the per-pass `getWorkspaceFileIndex` memo, which is where + * every caller already got them. That leaves one key shape for the indexes + * below and makes the `normalized`/`all` pairing structural rather than a + * contract the caller has to honour. `index` stays a parameter — the parity + * harness drives this resolver with and without one, and the no-index legs are + * a tested dimension, not a degenerate case. */ export function resolveCSharpImportInternal( importPath: string, csharpConfigs: CSharpProjectConfig[], - normalizedFileList: string[], - allFileList: string[], + allFilePaths: ReadonlySet, index?: SuffixIndex, evidence?: CSharpNamespaceEvidence, ): string[] { + const { normalized: normalizedFileList, all: allFileList } = getWorkspaceFileIndex(allFilePaths); const namespacePath = importPath.replace(/\./g, '/'); const results: string[] = []; @@ -62,34 +319,76 @@ export function resolveCSharpImportInternal( // 2. Try as directory: all .cs files directly inside (namespace import) if (index) { const dirFiles = index.getFilesInDir(dirPrefix, '.cs'); + // `getFilesInDir` already answers "directly inside a directory `D` where + // `D === dirPrefix || D.endsWith('/' + dirPrefix)`" — its keys ARE + // segment-aligned directory suffixes. So for a non-empty `dirPrefix` the + // direct-child re-check this loop used to run cannot reject anything, and + // measurement agrees: zero rejections over 12 008 (prefix, candidate) + // pairs. It rejected before #2881 only because it asked `indexOf` for the + // FIRST `//`, which is the rule that issue removed. + // + // That widening does not stay inside step 2's own bucket. This step + // returns as soon as it pushes anything, so a query it used to answer with + // nothing now also SUPPRESSES step 3, whose unanchored match set is a + // strict superset: over `SubModels/Models/F1.cs` + `SubModels/F3.cs`, + // `using App.Models` answered both through step 3 and now answers only the + // first through step 2. The new answer is the more precise one — a + // directory literally named `Models` beating a character-suffix hit on + // `SubModels` — and it is what this module's step-2-before-step-3 layering + // asks for, so it is kept rather than worked around. Pinned absolutely by + // the parity test, which is differentially blind to it (its frozen legacy + // copy moved in lockstep with this line). + // + // The empty prefix is the exception and keeps a real filter. `getDirMap` + // keys a file under every suffix of its DIRECTORY, so it emits the EMPTY + // one exactly when that directory's last component is empty: a leading '/' + // on a root-level file, or a doubled slash immediately before the file + // name. Probed against `getDirMap`'s own key emission: + // + // src/X.cs -> ['src:.cs'] no empty key + // /X.cs -> [':.cs'] empty key + // a//X.cs -> [':.cs', 'a/:.cs'] empty key + // /a/b/X.cs -> ['b:.cs', 'a/b:.cs', '/a/b:.cs'] no empty key + // + // So the `''` bucket is not "one directory deep" on its own — `a//X.cs` + // sits in it two components down — while step 3 answers that same query + // from `singleSegmentDirs`, which is. Filtering on `D` holding no slash is + // what rejects `a//X.cs` and keeps steps 2 and 3 in agreement. for (const f of dirFiles) { - const normalized = f.replace(/\\/g, '/'); - // Check it's a direct child by finding the dirPrefix and ensuring no deeper slashes - const prefixIdx = normalized.indexOf(dirPrefix + '/'); - if (prefixIdx < 0) continue; - const afterDir = normalized.substring(prefixIdx + dirPrefix.length + 1); - if (!afterDir.includes('/')) { - results.push(f); + if (dirPrefix === '') { + const normalized = f.replace(/\\/g, '/'); + const lastSlash = normalized.lastIndexOf('/'); + if (lastSlash < 0 || normalized.slice(0, lastSlash).includes('/')) continue; } + results.push(f); } if (results.length > 0) return results; } - // 3. Linear scan fallback for directory matching - if (results.length === 0) { - const dirTrail = dirPrefix + '/'; - for (let i = 0; i < normalizedFileList.length; i++) { - const normalized = normalizedFileList[i]; - if (!normalized.endsWith('.cs')) continue; - const prefixIdx = normalized.indexOf(dirTrail); - if (prefixIdx < 0) continue; - const afterDir = normalized.substring(prefixIdx + dirTrail.length); - if (!afterDir.includes('/')) { - results.push(allFileList[i]); - } - } - if (results.length > 0) return results; - } + // 3. Directory matching, UNANCHORED. + // + // Not redundant with step 2, and not skippable when `index` is present: + // `getFilesInDir` is keyed on SEGMENT suffixes of a directory, while this + // leg's predicate is an unanchored ends-with one, so it additionally + // answers `Models` with `src/SubModels/` and `src/Models` with + // `vendor/mysrc/Models/`. It is also the only leg that answers an empty + // `dirPrefix` — the `relative = ''` branch above (the import IS the root + // namespace) with no `projectDir` to stand in for it — because + // `buildSuffixIndex` emits an empty directory suffix only for a path that + // BEGINS with '/', so over repo-relative paths `getFilesInDir('', '.cs')` + // is always empty. See `CsharpNamespaceDirIndex` above for the index that + // replaced the per-import Θ(files) scan this used to be (#2902). + // + // `results` is provably empty here: step 2 returns as soon as it pushes + // anything, and so does this leg, so every iteration of the config loop + // starts empty. + pushFilesDirectlyInNamespaceDir( + getCsharpNamespaceDirIndex(allFilePaths), + dirPrefix, + allFileList, + results, + ); + if (results.length > 0) return results; } // Fallback: suffix matching without namespace stripping (single file). diff --git a/gitnexus/src/core/ingestion/import-resolvers/go.ts b/gitnexus/src/core/ingestion/import-resolvers/go.ts index c33c46422..eb87b0997 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/go.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/go.ts @@ -3,10 +3,23 @@ * * Strategy lives in configs/go.ts. * This file contains the shared helpers used by the strategy. + * + * **Reachability, as of #2929:** nothing in production calls either export + * today. The only path in is `configs/go.ts` → `createImportResolver` → + * the `importResolver` field on Go's `LanguageProvider`, and that field is + * read at exactly two lines — `import-target-adapter.ts:74-75` — whose two + * exports (`buildImportTargetWorkspace`, + * `resolveImportTargetAcrossLanguages`) have no importer anywhere but their + * own unit test. So this is a live-looking but currently unwired leg; the + * tests in `test/unit/import-resolvers/go-package-resolve.test.ts` are the + * only thing watching it. */ import type { GoModuleConfig } from '../language-config.js'; +/** `'/'`, for the parent-directory boundary check in `resolveGoPackage`. */ +const SLASH_CODE = 47; + /** * Extract the package directory suffix from a Go import path. * Returns the suffix string (e.g., "/internal/auth/") or null if invalid. @@ -25,32 +38,42 @@ export function resolveGoPackageDir(importPath: string, goModule: GoModuleConfig export function resolveGoPackage( importPath: string, goModule: GoModuleConfig, - normalizedFileList: string[], - allFileList: string[], + normalizedFileList: readonly string[], + allFileList: readonly string[], ): string[] { - if (!importPath.startsWith(goModule.modulePath)) return []; + // Identical to the six lines this used to re-derive; `resolveGoPackageDir` + // returns the '/'-wrapped form and the scan wants the bare path, so unwrap. + const pkgDir = resolveGoPackageDir(importPath, goModule); + if (pkgDir === null) return []; + const relativePkg = pkgDir.slice(1, -1); // "/internal/auth/" → "internal/auth" - // Strip module path to get relative package path - const relativePkg = importPath.slice(goModule.modulePath.length + 1); // e.g., "internal/auth" - if (!relativePkg) return []; - - const pkgSuffix = '/' + relativePkg + '/'; + const pkgLen = relativePkg.length; // >= 1: `resolveGoPackageDir` rejects empty const matches: string[] = []; for (let i = 0; i < normalizedFileList.length; i++) { - // Prepend '/' so paths like "internal/auth/service.go" match suffix "/internal/auth/" - const normalized = '/' + normalizedFileList[i]; - // File must be directly in the package directory (not a subdirectory) - if ( - normalized.includes(pkgSuffix) && - normalized.endsWith('.go') && - !normalized.endsWith('_test.go') - ) { - const afterPkg = normalized.substring(normalized.indexOf(pkgSuffix) + pkgSuffix.length); - if (!afterPkg.includes('/')) { - matches.push(allFileList[i]); - } - } + const normalized = normalizedFileList[i]; + if (!normalized.endsWith('.go') || normalized.endsWith('_test.go')) continue; + // The file's PARENT directory ends with the package path — the same + // predicate `package-dir-index.ts` states. This used to ask `indexOf` for + // the FIRST `//` and then check that nothing after it held a slash, + // which made `a/pkg/b/pkg/x.go` not a member of `pkg` (#2881). + // + // Expressed as "`relativePkg` sits immediately before the last slash, on a + // segment boundary". The boundary is either the start of the path (an + // import matching from index 0, `internal/auth/x.go`) or a `/` — which is + // what the old `'/' + path` cons bought, at the price of a per-file + // concatenation the first `endsWith` forced V8 to flatten (#2929). + // + // Rewriting this as `endsWith(relativePkg, lastSlash)` buys nothing: the + // two-argument overload measured a wash against `startsWith(needle, pos)` + // here (10.28 ns vs 9.82 ns), so it trades the clarity of an explicit start + // index for no gain. A "the 2-arg overload leaves V8's fast path, 20x" + // claim from review did not reproduce on Node 22.18 — its baseline was a + // one-argument call that early-exited on the length precheck. + const start = normalized.lastIndexOf('/') - pkgLen; // < 0 when there is no parent dir + if (start < 0 || !normalized.startsWith(relativePkg, start)) continue; + if (start > 0 && normalized.charCodeAt(start - 1) !== SLASH_CODE) continue; + matches.push(allFileList[i]); } return matches; diff --git a/gitnexus/src/core/ingestion/import-resolvers/jvm.ts b/gitnexus/src/core/ingestion/import-resolvers/jvm.ts index 194cfdac8..b6723b8e2 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/jvm.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/jvm.ts @@ -31,8 +31,8 @@ export const appendKotlinWildcard = (importPath: string, importNode: SyntaxNode) */ export function resolveJvmWildcard( importPath: string, - normalizedFileList: string[], - allFileList: string[], + normalizedFileList: readonly string[], + allFileList: readonly string[], extensions: readonly string[], index?: SuffixIndex, ): string[] { @@ -90,8 +90,8 @@ export function resolveJvmWildcard( */ export function resolveJvmMemberImport( importPath: string, - normalizedFileList: string[], - allFileList: string[], + normalizedFileList: readonly string[], + allFileList: readonly string[], extensions: readonly string[], index?: SuffixIndex, ): string | null { diff --git a/gitnexus/src/core/ingestion/import-resolvers/node-workspace-packages.ts b/gitnexus/src/core/ingestion/import-resolvers/node-workspace-packages.ts new file mode 100644 index 000000000..5750da917 --- /dev/null +++ b/gitnexus/src/core/ingestion/import-resolvers/node-workspace-packages.ts @@ -0,0 +1,528 @@ +/** + * In-repo `package.json` manifests, as module-resolution input (#2953). + * + * A bare specifier (`@acme/telemetry/nest`, `@repo/utils`, `lodash/fp`) names a + * PACKAGE, not a path, and the manifest is the only thing that says which + * packages exist and where their entry points are. Without it a resolver can do + * nothing but guess — which is what the old suffix matcher did, landing + * `@acme/telemetry/nest` on the repo's only path ending in `nest/index.ts` + * while `@repo/utils`, a real first-party package, resolved to nothing because + * its name appears in no file path at all. + * + * Both directions come from the same missing input, so both are fixed by + * reading it: every in-repo `package.json` contributes its `name`, its `exports` + * map (including subpath patterns), its legacy entry fields, and its `imports` + * map for `#`-prefixed specifiers. + */ + +import fs from 'fs/promises'; +import path from 'path'; +import { createRequire } from 'node:module'; + +import { isHardcodedIgnoredDirectoryAtPath } from '../../../config/ignore-service.js'; +import { logger } from '../../logger.js'; +import { resolveFile } from '../languages/typescript/file-candidates.js'; + +// `js-yaml` is CJS; the rest of this repository reaches it the same way +// (`core/group/config-parser.ts`, `cli/group.ts`). +const _require = createRequire(import.meta.url); +const yaml = _require('js-yaml') as typeof import('js-yaml'); + +/** One in-repo package. */ +export interface NodeWorkspacePackage { + /** Repo-relative directory holding the `package.json` (`''` for the root). */ + readonly dir: string; + /** + * Repo-relative entry stems for the package root (`import '@repo/utils'`), + * best first: declared `exports["."]`, then `module` / `main` / `types`, then + * the conventional `src/index` and `index`. + * + * A published `dist/...` entry simply fails to match an indexed source file + * (build output is not indexed) and the next candidate is tried, which is why + * the conventional fallbacks stay at the end rather than being a guess: they + * are what the package resolves to when it is consumed from source, which in + * a workspace it always is. + */ + readonly entries: readonly string[]; + /** + * Declared `exports` subpaths, specifier suffix -> repo-relative stems. + * Keys are as written minus the leading `./`, so `"./nest"` is stored `nest`; + * a pattern key keeps its `*` (`"./features/*"` -> `features/*`). + */ + readonly subpathExports: ReadonlyMap; + /** Declared `imports` map, `#name` -> repo-relative stems. */ + readonly subpathImports: ReadonlyMap; +} + +export interface NodeWorkspacePackages { + /** Package name (`@repo/utils`, `utils`) -> that package. */ + readonly byName: ReadonlyMap; +} + +const SCAN_MAX_DIRS = 20_000; +const SCAN_MAX_DEPTH = 24; + +/** + * The package name a bare specifier addresses, or `null` when the specifier + * names a path rather than a package. + * + * `@acme/telemetry/nest` -> `@acme/telemetry`, `lodash/fp` -> `lodash`. + */ +export function nodePackageNameOf(specifier: string): string | null { + if (specifier === '' || specifier.startsWith('.') || specifier.startsWith('/')) return null; + if (specifier.startsWith('#')) return null; + if (specifier.startsWith('@')) { + const parts = specifier.split('/'); + return parts.length >= 2 && parts[0].length > 1 && parts[1] !== '' + ? `${parts[0]}/${parts[1]}` + : null; + } + return specifier.split('/')[0] || null; +} + +/** The in-repo package whose directory most closely contains `filePath`. */ +export function owningPackage( + filePath: string, + packages: NodeWorkspacePackages | null | undefined, +): NodeWorkspacePackage | null { + if (!packages) return null; + let best: NodeWorkspacePackage | null = null; + for (const pkg of packages.byName.values()) { + const inside = pkg.dir === '' || filePath.startsWith(`${pkg.dir}/`); + if (inside && (best === null || pkg.dir.length > best.dir.length)) best = pkg; + } + return best; +} + +/** + * Resolve a bare specifier that names an in-repo package. + * + * `null` means the specifier names no in-repo package — an external dependency, + * whose correct in-repo resolution is nothing — or names one that does not + * export the requested subpath. + */ +export function resolveNodeWorkspaceImport( + specifier: string, + packages: NodeWorkspacePackages | null | undefined, + allFiles: ReadonlySet, +): string | null { + if (!packages) return null; + const packageName = nodePackageNameOf(specifier); + if (packageName === null) return null; + const pkg = packages.byName.get(packageName); + if (pkg === undefined) return null; + + const subpath = specifier.slice(packageName.length).replace(/^\//, ''); + for (const stem of entryStemsFor(pkg, subpath)) { + const hit = resolveFile(stem, allFiles); + if (hit !== null) return hit; + } + return null; +} + +/** + * Look a specifier up in a subpath map — `exports` or `imports`, which share + * Node's matching rule exactly: an exact key wins, otherwise the pattern with + * the longest literal prefix does, and its `*` takes whatever the specifier put + * there. + * + * Shared because they diverged once: the `imports` side did an exact lookup + * only, so a declared `"#internal/*"` could never match `#internal/foo`. + */ +export function matchSubpathMap( + map: ReadonlyMap, + specifier: string, +): readonly string[] | null { + const exact = map.get(specifier); + if (exact !== undefined) return exact; + + const patterns = [...map.entries()] + .filter(([key]) => key.includes('*')) + .map(([key, stems]) => { + const star = key.indexOf('*'); + return { prefix: key.slice(0, star), suffix: key.slice(star + 1), stems }; + }) + .filter( + ({ prefix, suffix }) => + specifier.startsWith(prefix) && + specifier.endsWith(suffix) && + specifier.length >= prefix.length + suffix.length, + ) + .sort((a, b) => b.prefix.length - a.prefix.length); + + for (const { prefix, suffix, stems } of patterns) { + const stem = specifier.slice(prefix.length, specifier.length - suffix.length); + return stems.map((target) => substituteStar(target, stem)); + } + return null; +} + +/** + * Substitute a subpath pattern's single `*`. + * + * Node's subpath patterns and TypeScript's `paths` both allow AT MOST one `*`, + * so replacing the first occurrence is the specified behaviour rather than a + * partial one — but `String.replace` with a string needle says that only by + * accident, and reads as a bug to anyone (CodeQL included) who has met the + * replace-all footgun. Slicing at the known index states the rule instead. + */ +export function substituteStar(target: string, stem: string): string { + const star = target.indexOf('*'); + return star === -1 ? target : target.slice(0, star) + stem + target.slice(star + 1); +} + +/** Candidate stems for one specifier into `pkg`, best first. */ +function entryStemsFor(pkg: NodeWorkspacePackage, subpath: string): readonly string[] { + if (subpath === '') return pkg.entries; + + const declared = matchSubpathMap(pkg.subpathExports, subpath); + if (declared !== null) return declared; + + // A package with NO `exports` map is not restricted: Node resolves any + // subpath against the package DIRECTORY, and only against it. A package WITH + // one exposes only what it lists, so an unlisted subpath resolves to nothing. + // + // Both restrictions are real, and neither is softened here. An earlier draft + // also tried `/src/`, on the theory that a workspace package is + // consumed from source — but nothing declares that mapping, so it is the same + // kind of guess this module exists to remove: it would resolve + // `@repo/utils/deep/thing` to `packages/utils/src/deep/thing.ts` for a + // package whose manifest never said `deep/thing` lives under `src/`, and the + // import would be broken in the real project too. + if (pkg.subpathExports.size > 0) return []; + return [joinRepoPath(pkg.dir, subpath)]; +} + +/** + * The directories the workspace ADMITS as packages. + * + * `null` means the repository declares no workspace at all, in which case the + * only package is the one at the root — a nested `package.json` somewhere in + * `examples/` or `test/fixtures/` is not a member of anything and its name is + * not addressable by an import. + * + * This gate is the difference between reading manifests and trusting them. + * Without it, finding a `package.json` anywhere in the tree was enough to + * register its name, which recreates the false-positive half of #2953 from a + * different source: an app importing registry package `foo` would bind to an + * excluded fixture that happens to declare `name: "foo"`. THIS repository is + * the example — `test/fixtures/**` alone declares `@repo/utils` (added by this + * very change) among others. + */ +interface WorkspaceScope { + /** Positive patterns, repo-relative, as declared. */ + readonly include: readonly string[]; + /** `!`-prefixed patterns, with the `!` stripped. */ + readonly exclude: readonly string[]; +} + +/** Whether `dir` (repo-relative, `''` for the root) is an admitted package. */ +function admits(scope: WorkspaceScope | null, dir: string): boolean { + // The root package is always itself, workspace or not. + if (dir === '') return true; + if (scope === null) return false; + if (scope.exclude.some((pattern) => globToRegExp(pattern).test(dir))) return false; + return scope.include.some((pattern) => globToRegExp(pattern).test(dir)); +} + +/** + * Match one workspace glob. + * + * The subset npm, pnpm, yarn and lerna actually use in `workspaces` / + * `packages`: `*` within a segment, `**` across segments, `?`, and a leading + * `!` for exclusion (handled by the caller). Deliberately not a general glob + * engine — the patterns are a documented, narrow dialect, and `minimatch` is + * only present here transitively through `glob`. + */ +function globToRegExp(pattern: string): RegExp { + const normalized = pattern.replace(/^\.\//, '').replace(/\/$/, ''); + let out = ''; + for (let i = 0; i < normalized.length; i++) { + const ch = normalized[i]; + if (ch === '*') { + if (normalized[i + 1] === '*') { + // `**/` may match nothing at all, so `packages/**/x` also matches + // `packages/x`; a trailing `**` matches any depth below. + if (normalized[i + 2] === '/') { + out += '(?:.*/)?'; + i += 2; + } else { + out += '.*'; + i += 1; + } + } else { + out += '[^/]*'; + } + continue; + } + if (ch === '?') { + out += '[^/]'; + continue; + } + out += ch.replace(/[.+^${}()|[\]\\]/g, '\\$&'); + } + return new RegExp(`^${out}$`); +} + +/** + * Read the repository's workspace declaration. + * + * All three spellings are read and merged, because a repo may carry more than + * one (a pnpm workspace whose root `package.json` also lists `workspaces` for + * tooling that does not read pnpm's file). + */ +async function loadWorkspaceScope(repoRoot: string): Promise { + const patterns: string[] = []; + + const rootManifest = await readJsonFile(path.join(repoRoot, 'package.json')); + const workspaces = rootManifest?.workspaces; + if (Array.isArray(workspaces)) { + patterns.push(...workspaces.filter((w): w is string => typeof w === 'string')); + } else if (workspaces !== null && typeof workspaces === 'object') { + // Yarn's object form: `{ "packages": [...], "nohoist": [...] }`. + const nested = (workspaces as { packages?: unknown }).packages; + if (Array.isArray(nested)) { + patterns.push(...nested.filter((w): w is string => typeof w === 'string')); + } + } + + patterns.push(...(await readYamlPackages(path.join(repoRoot, 'pnpm-workspace.yaml')))); + patterns.push(...(await readYamlPackages(path.join(repoRoot, 'pnpm-workspace.yml')))); + + const lerna = await readJsonFile(path.join(repoRoot, 'lerna.json')); + if (Array.isArray(lerna?.packages)) { + patterns.push(...lerna.packages.filter((w): w is string => typeof w === 'string')); + } + + if (patterns.length === 0) return null; + return { + include: patterns.filter((p) => !p.startsWith('!')), + exclude: patterns.filter((p) => p.startsWith('!')).map((p) => p.slice(1)), + }; +} + +async function readJsonFile(filePath: string): Promise | null> { + try { + return JSON.parse(await fs.readFile(filePath, 'utf-8')) as Record; + } catch { + return null; + } +} + +async function readYamlPackages(filePath: string): Promise { + let raw: string; + try { + raw = await fs.readFile(filePath, 'utf-8'); + } catch { + return []; + } + try { + const parsed = yaml.load(raw) as { packages?: unknown } | null; + const packages = parsed?.packages; + return Array.isArray(packages) + ? packages.filter((p): p is string => typeof p === 'string') + : []; + } catch { + return []; + } +} + +/** + * Collect the `package.json` of every ADMITTED workspace package. + * + * Directory-only BFS: the sole files opened are manifests and the workspace + * declaration, so this is far cheaper than the C# namespace scan next door, + * which reads every `.cs` file. + */ +export async function loadNodeWorkspacePackages( + repoRoot: string, +): Promise { + const scope = await loadWorkspaceScope(repoRoot); + const byName = new Map(); + const queue: { dir: string; depth: number }[] = [{ dir: repoRoot, depth: 0 }]; + let dirsScanned = 0; + + while (queue.length > 0) { + if (dirsScanned >= SCAN_MAX_DIRS) { + logger.warn( + `[node] package.json scan of ${repoRoot} hit the ${SCAN_MAX_DIRS}-directory cap; workspace packages below it will not resolve`, + ); + break; + } + const { dir, depth } = queue.shift()!; + dirsScanned++; + + let entries: import('fs').Dirent[]; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + if (entry.isDirectory()) { + const childDir = path.join(dir, entry.name); + if (isHardcodedIgnoredDirectoryAtPath(repoRoot, childDir)) continue; + if (depth < SCAN_MAX_DEPTH) { + queue.push({ dir: childDir, depth: depth + 1 }); + } + continue; + } + if (!entry.isFile() || entry.name !== 'package.json') continue; + + const relDir = repoRelativeDir(repoRoot, dir); + // Found is not the same as admitted. A manifest outside the declared + // workspace belongs to something this repository does not build — a + // fixture, an example, a vendored copy — and its name is not addressable. + if (!admits(scope, relDir)) continue; + + const pkg = await readManifest(path.join(dir, entry.name), repoRoot, dir); + // First declaration wins: BFS visits shallower directories first, so a + // top-level package outranks a nested one that reuses the name. + if (pkg !== null && !byName.has(pkg.name)) byName.set(pkg.name, pkg.package); + } + } + + return byName.size === 0 ? null : { byName }; +} + +async function readManifest( + manifestPath: string, + repoRoot: string, + dir: string, +): Promise<{ name: string; package: NodeWorkspacePackage } | null> { + let parsed: Record; + try { + parsed = JSON.parse(await fs.readFile(manifestPath, 'utf-8')) as Record; + } catch { + return null; + } + const name = typeof parsed.name === 'string' ? parsed.name : ''; + if (name === '') return null; + + const packageDir = repoRelativeDir(repoRoot, dir); + const rebase = (raw: string): string => joinRepoPath(packageDir, stripEntryPrefixes(raw)); + + const subpathExports = new Map(); + const rootExports: string[] = []; + collectExports(parsed.exports, subpathExports, rootExports, rebase); + + // `exports`, when present, is the package's ENTIRE public interface: Node + // ignores `main` outright and refuses any subpath the map does not list. This + // resolver already honoured that restriction for subpaths (`entryStemsFor`) + // and not for the ROOT, which is the same rule — so a manifest exporting only + // `"./feature"` still answered a bare `@repo/pkg` with `src/index`, an edge + // for an import that does not resolve in the real project. + const declaresExports = parsed.exports !== undefined && parsed.exports !== null; + const entries: string[] = [...rootExports]; + if (!declaresExports) { + for (const field of ['module', 'main', 'types', 'typings']) { + const value = parsed[field]; + if (typeof value === 'string') push(entries, rebase(value)); + } + for (const conventional of ['src/index', 'index', 'lib/index']) { + push(entries, joinRepoPath(packageDir, conventional)); + } + } + + const subpathImports = new Map(); + collectImports(parsed.imports, subpathImports, rebase); + + return { name, package: { dir: packageDir, entries, subpathExports, subpathImports } }; +} + +/** + * Walk an `exports` value into the root-entry list and the subpath map. + * + * `exports` nests three ways at once — a bare string, a subpath map, and + * condition maps (`import` / `require` / `types` / `default`) at any depth — so + * this collects string leaves per subpath rather than assuming a shape. + */ +function collectExports( + node: unknown, + subpaths: Map, + rootStems: string[], + rebase: (raw: string) => string, + currentSubpath: string | null = '', +): void { + if (typeof node === 'string') { + if (currentSubpath === null) return; + if (currentSubpath === '') { + push(rootStems, rebase(node)); + return; + } + subpaths.set(currentSubpath, [...(subpaths.get(currentSubpath) ?? []), rebase(node)]); + return; + } + // An array is an ordered FALLBACK LIST, not an opaque value: Node tries each + // entry in turn. `{"./feature": ["./dist/feature.js", "./src/feature.ts"]}` is + // the shape a workspace package publishes to say "built output, or source" — + // and the source arm is the one that matters here, because `dist/` is build + // output and is not indexed. Skipping arrays dropped the declaration entirely + // and left the package looking as though it declared no subpath exports. + if (Array.isArray(node)) { + for (const element of node) + collectExports(element, subpaths, rootStems, rebase, currentSubpath); + return; + } + if (node === null || typeof node !== 'object') return; + + for (const [key, value] of Object.entries(node as Record)) { + if (key.startsWith('.')) { + // A subpath key: `"."` is the package root, `"./nest"` the subpath `nest`. + collectExports( + value, + subpaths, + rootStems, + rebase, + key === '.' ? '' : key.replace(/^\.\//, ''), + ); + } else { + // A condition key — stays on whatever subpath we were already resolving. + collectExports(value, subpaths, rootStems, rebase, currentSubpath); + } + } +} + +/** Walk an `imports` map (`"#env": "./src/env.node.ts"`) into stems. */ +function collectImports( + node: unknown, + out: Map, + rebase: (raw: string) => string, + currentKey: string | null = null, +): void { + if (typeof node === 'string') { + if (currentKey === null) return; + out.set(currentKey, [...(out.get(currentKey) ?? []), rebase(node)]); + return; + } + // Same ordered-fallback rule as `exports` — see `collectExports`. + if (Array.isArray(node)) { + for (const element of node) collectImports(element, out, rebase, currentKey); + return; + } + if (node === null || typeof node !== 'object') return; + for (const [key, value] of Object.entries(node as Record)) { + collectImports(value, out, rebase, key.startsWith('#') ? key : currentKey); + } +} + +/** `"./src/index.ts"` -> `"src/index"`; leaves an extension-less path alone. */ +function stripEntryPrefixes(entry: string): string { + const withoutDot = entry.replace(/^\.\//, '').replace(/^\//, ''); + return withoutDot.replace(/\.(ts|tsx|mts|cts|js|jsx|mjs|cjs|vue)$/, ''); +} + +function push(list: string[], value: string): void { + if (value !== '' && !list.includes(value)) list.push(value); +} + +/** `/repo/packages/utils` -> `packages/utils`; the root -> `''`. */ +function repoRelativeDir(repoRoot: string, dir: string): string { + const rel = path.relative(repoRoot, dir).split(path.sep).join('/'); + return rel === '.' ? '' : rel; +} + +function joinRepoPath(dir: string, rest: string): string { + return dir === '' ? rest : `${dir}/${rest}`; +} diff --git a/gitnexus/src/core/ingestion/import-resolvers/package-dir-index.ts b/gitnexus/src/core/ingestion/import-resolvers/package-dir-index.ts new file mode 100644 index 000000000..cb7e77ed0 --- /dev/null +++ b/gitnexus/src/core/ingestion/import-resolvers/package-dir-index.ts @@ -0,0 +1,255 @@ +/** + * "Which files live DIRECTLY inside a directory whose path ends with + * ``?" — the query Go's package resolution and C#'s namespace-directory + * fallback both answered with a full `allFilePaths` scan per import. + * + * Both scans ran the same predicate: normalize to forward slashes, apply the + * language's extension filter, find the FIRST `'/' + pkgPath + '/'` occurrence, + * and keep the file only if nothing after that occurrence contains a slash. + * + * That predicate depends only on the file's DIRECTORY, so it can be answered + * from an index built once per file set: + * + * let D = '/' + + '/' + * let P = '/' + pkgPath + '/' + * match ⟺ D.endsWith(P) + * + * It used to say one more thing, and #2881 removed it: + * + * match ⟺ D.length >= P.length && D.indexOf(P) === D.length - P.length + * + * — i.e. `D` ends with `P` AND that trailing occurrence is the FIRST one, so + * `a/pkg/b/pkg/x.go` did NOT answer `pkg`. The second half was never a rule + * anyone chose. It is what the pre-index per-import scan happened to compute + * (it called `indexOf`, then checked that nothing after the match contained a + * slash), and the index was built to reproduce that scan byte for byte. It + * dropped exactly the repositories that nest a directory name inside itself: + * `internal/…/internal`, `Models/…/Models`, and the reported shape + * `data/src/main/kotlin/com/example/data/Repo.kt`, where `import data.helper` + * resolved to null. Kotlin was fixed first, in its own `dirChildren` + * (`languages/kotlin/import-target.ts`); this index, the C# csproj index and + * the legacy `go.ts` scan followed. + * + * The strongest evidence that the rule was accidental is that a sixth + * implementation of the same question never had it. `import-resolvers/jvm.ts` + * answers "files directly inside a directory ending with " for + * Java and Kotlin wildcard imports, and has used `lastIndexOf` since #488. + * + * That is evidence about how the predicate was WRITTEN, not about live + * behaviour, and the distinction matters enough to spell out. `jvm.ts` is + * reached only through `provider.importResolver`, which `languages/java.ts` and + * `languages/kotlin.ts` do wire — but that field currently has no production + * READER. Its only reader anywhere is `import-target-adapter.ts`, whose own + * docblock says it is "threaded through `finalizeScopeModel`"; nothing threads + * it, and neither that module nor its two exports + * (`buildImportTargetWorkspace`, `resolveImportTargetAcrossLanguages`) is + * referenced outside its own unit test. So `jvm.ts`'s `resolveJvmWildcard` and + * `import-resolvers/go.ts`'s `resolveGoPackage` are dormant, while THIS index, + * `csharp.ts`'s `resolveCSharpImportInternal` and Kotlin's `dirChildren` are + * the ones that run. Whether those two dormant resolvers should be deleted or + * actually wired up is an open question and wants its own issue; it is not + * settled here. + * + * The argument survives that correction intact, because it never needed the + * resolvers to be live: an independent implementation of the same question, + * written without reference to the pre-index scan, reached for `lastIndexOf`. + * The extra clause was never a rule anyone chose. All six spellings now agree. + * + * The length guard the `indexOf` form needed is gone with it: `endsWith` is + * false for a shorter `D` instead of comparing -1 to -1. + * + * Candidates are narrowed by the directory's LAST segment rather than by + * indexing every directory suffix: a suffix map costs O(files × depth) entries, + * which is exactly the memory this codebase runs out of at kernel scale + * (#2649), while the last-segment bucket is O(directories) and is a superset of + * the matches (`D` ends with `P` ⟹ the dir's last segment is `pkgPath`'s last + * segment). + * + * Results keep Set-iteration order via the recorded `ord`, because the callers' + * scans emitted in that order and Go returns the whole list as the import + * target (one `ImportEdge` per file). + * + * Each language owns its own `WeakMap` memo and `accept` predicate, so the + * STORED index holds only that language's files — the build pass itself still + * walks every path it is handed once per language. That is not a polyglot tax + * in practice: `scope-resolution/pipeline/run.ts:673` rebuilds `allFilePaths` + * from the provider's own `parsedFiles`, so the set already contains only that + * language's files. + */ + +interface IndexedFile { + readonly raw: string; + /** + * Position in `allFilePaths` iteration order. Still load-bearing: + * `filesDirectlyInPkgDir` sorts on it to interleave several directories back + * into the order the original single-pass scan emitted. + */ + readonly ord: number; +} + +/** + * Deeply read-only on purpose. The memo hoist turned what used to be per-call + * scratch into state shared by every import in a run, and `readonly` on the + * PROPERTY still lets a caller do `idx.rootFiles.sort()` in place. Typing the + * containers as read-only makes the copy-before-mutating rule compile-enforced + * instead of comment-enforced — but `readonly` is erased at runtime and is not + * hard to widen back (the sibling Kotlin index documents `Array.isArray`'s + * `arg is any[]` predicate doing exactly that), so the one container callers + * read directly is handed out through `sortedRootFiles` rather than raw. + */ +export interface PackageDirIndex { + /** Last path segment of a directory → every normalized directory ending in it. */ + readonly dirsByLastSegment: ReadonlyMap; + /** Normalized directory → the accepted files directly inside it, in Set order. */ + readonly filesByDir: ReadonlyMap; + /** Accepted files with no directory at all, in Set order. */ + readonly rootFiles: readonly string[]; +} + +/** + * @param accept Runs on the normalized (forward-slash) path; return `false` to + * leave the file out of the index entirely. + */ +export function buildPackageDirIndex( + allFilePaths: ReadonlySet, + accept: (normalized: string) => boolean, +): PackageDirIndex { + const dirsByLastSegment = new Map(); + const filesByDir = new Map(); + const rootFiles: string[] = []; + + let ord = 0; + for (const raw of allFilePaths) { + const ownOrd = ord++; + const normalized = raw.replace(/\\/g, '/'); + if (!accept(normalized)) continue; + + const lastSlash = normalized.lastIndexOf('/'); + if (lastSlash < 0) { + // No directory: `'/x.go'.indexOf('/pkg/')` can never hit, so a root file + // answers no `pkgPath` query. Kept separately for Go's root-package leg. + rootFiles.push(raw); + continue; + } + + const dir = normalized.slice(0, lastSlash); + let files = filesByDir.get(dir); + if (files === undefined) { + files = []; + filesByDir.set(dir, files); + const lastSegment = dir.slice(dir.lastIndexOf('/') + 1); + let dirs = dirsByLastSegment.get(lastSegment); + if (dirs === undefined) { + dirs = []; + dirsByLastSegment.set(lastSegment, dirs); + } + dirs.push(dir); + } + files.push({ raw, ord: ownOrd }); + } + + return { dirsByLastSegment, filesByDir, rootFiles }; +} + +/** Every indexed directory matching `pkgPath`, in first-seen order. */ +function* matchingDirs(index: PackageDirIndex, pkgPath: string): Generator { + const lastSegment = pkgPath.slice(pkgPath.lastIndexOf('/') + 1); + const dirs = index.dirsByLastSegment.get(lastSegment); + if (dirs === undefined) return; + // `('/' + D + '/').endsWith('/' + P + '/')` ⟺ `D === P || D.endsWith('/' + P)`, + // which is the same predicate without the two strings per candidate the + // wrapped form built: 32.58 ns → 8.22 ns per candidate, 3.96x, over 2001 + // directories of which 668 match (Node 22.18.0, best-of-80 after 300 warmup + // passes). Verified exhaustively rather than argued, over every pair of + // strings up to length 5 over `{a, b, /}` including the empty string — + // 132 496 pairs, 911 of them matching: 0 divergences. The match count is + // reported beside the timing on purpose: two predicates that agree on `false` + // everywhere also show 0 divergences. + // + // Worth the care because this loop is genuinely hot: Go's GOPATH fallback + // calls `matchingDirs` once per import-path segment over the bucket holding + // EVERY directory that shares the queried last segment (every service's + // `internal`). At 1000 services × 100 000 unresolved imports × 4 segments + // that is ~34.6 s against ~14.0 s, and ~0.5 GB of transient garbage not + // allocated. + // + // There is nothing further to win by reaching for the two-argument + // `endsWith(search, endPosition)` or for `startsWith(needle, pos)`: on the + // same data all three land together — 8.15 ns one-argument, 8.55 ns + // two-argument, 8.54 ns `startsWith` — and against the unwrapped string the + // two-argument form is character-for-character the same test. A "the 2-arg + // overload leaves V8's fast path, 20x" claim was measured during review and + // did NOT reproduce here or in two independent re-runs; its 1.87 ns baseline + // was a one-argument call whose needle failed the length/last-char precheck + // and early-exited without comparing. Recorded because the retraction is the + // useful part: compare forms that do the same work and report the hit count. + // + // The equality arm also carries the length guard the `indexOf` form needed: a + // shorter `dir` is simply false, where `indexOf` returned -1 and + // `haystack.length - needle.length` could also be -1 and report a bogus match. + const suffix = `/${pkgPath}`; + for (const dir of dirs) { + if (dir !== pkgPath && !dir.endsWith(suffix)) continue; + const files = index.filesByDir.get(dir); + if (files !== undefined) yield files; + } +} + +/** + * Every accepted file directly inside a directory ending with `pkgPath`, in + * `allFilePaths` iteration order. + */ +export function filesDirectlyInPkgDir(index: PackageDirIndex, pkgPath: string): string[] { + // The first bucket is held by reference, not copied into an accumulator: one + // matching directory is the overwhelmingly common case (every unique-leaf + // call, and any query whose package path has more than one segment), and it + // then reaches the `map` with zero intermediate copies. + // + // A second directory promotes that reference to a real accumulator, which is + // appended to once per file from then on — never re-spread per directory, + // because that costs O(files × dirs²) copies, which a monorepo carrying the + // same package directory under many services (`svcN/internal/models`, queried + // by Go's two-segment GOPATH tail) would pay on every import. + let first: readonly IndexedFile[] | null = null; + let merged: IndexedFile[] | null = null; + for (const files of matchingDirs(index, pkgPath)) { + if (first === null) { + first = files; + continue; + } + if (merged === null) merged = [...first]; + for (const f of files) merged.push(f); + } + if (first === null) return []; + // One directory is already in Set order; several interleave and need merging + // back onto the order the original single-pass scan emitted. + if (merged === null) return first.map((f) => f.raw); + merged.sort((a, b) => a.ord - b.ord); + return merged.map((f) => f.raw); +} + +/** Root-package files in sorted order. Copies: the index array is shared by + * every import in the run and the result leaves as an edge target list. */ +export function sortedRootFiles(index: PackageDirIndex): string[] { + return [...index.rootFiles].sort(); +} + +/** + * The FIRST accepted file (in `allFilePaths` iteration order) directly inside a + * directory ending with `pkgPath`, or `null`. + */ +export function firstFileDirectlyInPkgDir(index: PackageDirIndex, pkgPath: string): string | null { + // Returning the FIRST match is already the minimum-`ord` answer, and it is + // the build loop that makes it so: `buildPackageDirIndex` appends a directory + // to its last-segment bucket at the moment it accepts that directory's first + // file, so bucket order IS ascending first-file-`ord` order. Comparing `ord` + // across the remaining directories can never improve on the first hit + // (differentially verified: 0 divergences). Change that append point — buffer + // the directories, sort them, populate `filesByDir` before `dirsByLastSegment` + // — and this early return silently starts answering with the wrong file. + for (const files of matchingDirs(index, pkgPath)) { + const first = files[0]; + if (first !== undefined) return first.raw; + } + return null; +} diff --git a/gitnexus/src/core/ingestion/import-resolvers/pass-cache.ts b/gitnexus/src/core/ingestion/import-resolvers/pass-cache.ts new file mode 100644 index 000000000..4c307cfd0 --- /dev/null +++ b/gitnexus/src/core/ingestion/import-resolvers/pass-cache.ts @@ -0,0 +1,79 @@ +import { buildSuffixIndex, type SuffixIndex } from './utils.js'; + +/** + * Everything the standard `resolveTsTarget` path derives from one workspace + * file set: the file list, the lower-cased file list, the suffix index and the + * per-pass `resolveCache`. + * + * Without this memoization the resolver re-derived `allFileList` and + * `normalizedFileList` (both O(N_files)), rebuilt the index and threw away the + * `resolveCache` on every import — O(N_files × N_imports) total work for what + * should be O(N_files + N_imports). + */ +export interface ImportPassCache { + readonly allFilePaths: Set; + readonly allFileList: readonly string[]; + readonly normalizedFileList: readonly string[]; + readonly index: SuffixIndex; + readonly resolveCache: Map; +} + +/** + * Build that state. Shared by every adapter whose resolution runs through + * `resolveTsTarget`. + * + * Not a dedup of identical copies, and the difference is the point. At + * 49c5b7d81 each of those adapters carried this record inline and they did NOT + * agree: `languages/typescript/scope-resolver.ts` and + * `languages/vue/import-target.ts` held six byte-identical fields built around + * `index: buildSuffixIndex(normalizedFileList, allFileList)`, while + * `languages/javascript/import-target.ts` held five and never called + * `buildSuffixIndex` at all. That one missing field IS the O(imports × files) + * defect PR #2911 fixed — `resolveTsTarget` fell back to `suffixResolve`'s + * linear scan for every JavaScript import — and the header of + * `languages/javascript/import-target.ts` carries the measurements. Hoisting + * the builder is what makes a fourth adapter unable to omit it again: `index` + * is not optional on `ImportPassCache`. + * + * The BUILDER is shared; the MEMO deliberately is not. Each adapter wraps this + * in its own `perFileSet(...)`, so each gets its own `WeakMap`, its own index + * instance and — the one that would be a behaviour change — its own + * `resolveCache`. The languages disagree about what a specifier resolves to + * (`tsconfigPaths` is read from config for TypeScript and Vue, pinned to `null` + * for JavaScript, and the tried extension list differs), so one shared resolve + * cache across them would hand a language another language's answers. + * + * Sharing the builder is a code dedup and nothing more: it buys no runtime + * reuse, because there is none to buy. Each provider pass builds its own + * `allFilePaths` Set (`scope-resolution/pipeline/run.ts`, per provider), so + * TypeScript's set and JavaScript's set are different objects and therefore + * different `WeakMap` keys even where the two memos are the same code. + */ +export function buildImportPassCache(allFilePaths: ReadonlySet): ImportPassCache { + const allFileList = Array.from(allFilePaths); + // LOWERCASED, not slash-normalized — unlike every other caller of + // `buildSuffixIndex`. That is what `alreadyLowercased` below records. + const normalizedFileList = allFileList.map((f) => f.toLowerCase()); + return { + // Copied ONCE per file set, not once per import: `TsResolveContext` wants a + // mutable `Set` and the orchestrator hands us a `ReadonlySet`. The copy is + // not the #1918 hazard because the cache KEY is the caller's original Set. + allFilePaths: new Set(allFilePaths), + allFileList, + normalizedFileList, + // Every suffix of an all-lowercase path is itself lowercase, so the index's + // case-folded map came out a byte-for-byte copy of its exact map — same + // keys, same values, same insertion order — one per `ImportPassCache`, so + // once per adapter per pass. Measured 14.00 MiB at 32 000 paths, 29.8% of + // the retained `ImportPassCache`. The flag drops the copy; it does not change + // what `getInsensitive` answers, because the copy was the identity (see + // `SuffixIndexOptions`). Checked, not assumed: over 474 524 probes on four + // mixed-case corpora — Vue PascalCase plus alias specifiers, case-colliding + // twins, a 600-file deep monorepo, and Unicode paths carrying final sigma, + // dotted-I and sharp-S — the two maps came out byte-identical, the exact + // map was the sole answerer 0 times, and `get(s) || getInsensitive(s)` + // returned the same file 474 524 times out of 474 524. + index: buildSuffixIndex(normalizedFileList, allFileList, { alreadyLowercased: true }), + resolveCache: new Map(), + }; +} diff --git a/gitnexus/src/core/ingestion/import-resolvers/per-file-set.ts b/gitnexus/src/core/ingestion/import-resolvers/per-file-set.ts new file mode 100644 index 000000000..4de0ee361 --- /dev/null +++ b/gitnexus/src/core/ingestion/import-resolvers/per-file-set.ts @@ -0,0 +1,83 @@ +/** + * The one memo every per-file-set index in this pipeline is built on. + * + * The scope-resolution orchestrator builds ONE file-set object per provider + * pass and threads that same object through every `resolveImportTarget` call in + * the pass, so anything derived from it — a suffix index, a package-directory + * map, a basename bucket — can be built once and read by every import instead + * of rebuilt per import. Keying on the object's IDENTITY is what makes that + * work, and it is equally the contract callers must keep: the set is passed + * THROUGH, never copied. A defensive `new Set(allFilePaths)` at an adapter + * boundary hands a fresh key per import and silently restores + * O(imports × files) — the bug PR #1918 shipped and had to fix in review (P1). + * The guards are `test/integration/-import-index-reuse.test.ts` and, for + * every registered language at once, + * `test/unit/scope-resolution/import-target-index-reuse.contract.test.ts`, + * whose inventory arm fails when an entry of `SCOPE_RESOLVERS` has no fixture. + * That arm is why no language is named here: the registry is the census, and a + * hand-copied list of languages goes stale the release after it is written. + * + * A `WeakMap` rather than a `Map`: the entry is reclaimed with the file set it + * was derived from, so a pass can never read a previous pass's index and memory + * does not grow across runs. There is no invalidation rule to get wrong because + * there is nothing to invalidate — a new file set is a new key. + * + * The KEY TYPE is constrained rather than described, because which object is + * the key decides whether the guards above can see the memo fail, and a prose + * list of call sites is the thing this file elsewhere tells you not to write. + * `K` admits exactly the two shapes the orchestrator keeps stable for a pass: + * + * - `ReadonlySet`, the pass's file set — every index derived from it, + * including the derived header-closure sets that `languages/{c,cpp}/ + * scope-resolver.ts` memoize inside an outer per-file-set memo. Defeating + * one of these means copying the SET, which re-traverses it, which the + * `CountingSet` instrument (`test/helpers/counting-file-set.ts`) reads as a + * scan count rising with the import count. + * - `readonly ParsedFile[]`, the pass's parsed-file array. Not derived from + * the file set at all, so the file-set guards do not reach them; these key + * on the array the orchestrator already threads through the pass, and their + * contract is that same pass-through discipline. The instrument that CAN see + * them counts element reads on that array — `countedParsedFiles`, beside + * `CountingSet`, driven by the contract test's `minimumParsedFileReads`. + * + * A THIRD shape — an array materialized from the file set — is what the type + * exists to reject. `import-resolvers/csharp.ts` used one until #2911, and it + * is worth a compile error rather than a rule: copying an array mints a fresh + * `WeakMap` key while traversing the Set zero extra times, so every + * scan-counting guard stays green at its correct value while the index rebuilds + * once per import. That failure is invisible to the whole instrument family + * above and was caught only by a timing ratio in `bench/import-target/`. Derive + * the array inside the builder from `getWorkspaceFileIndex(allFilePaths)` + * instead. `string[]` is not assignable to `K`, so the shape cannot come back + * silently — `configs/swift.ts` keeps the one hand-rolled `WeakMap` on + * `ctx.allFileList` in the tree, deliberately and with its reasons written + * down, and it is deliberately NOT on this primitive. + * + * `T extends object` is deliberate, chosen over probing `has` before `get`. + * `WeakMap.get` returning `undefined` cannot distinguish "not built yet" from + * "built, and the value is `undefined`"; constraining the value to an object + * makes the second case unrepresentable rather than paying a second lookup on + * every import, and it needs no cast to type-check. Every index memoized here + * is a record, `Map` or `Set`, so the constraint costs nothing today — and a + * later caller wanting to memoize a `string | null` gets a compile error + * pointing at this line instead of a memo that silently rebuilds on every miss. + * + * A `build` that THROWS stores nothing, so the next call for that key runs it + * again: failures are not memoized, and a half-filled index is never published. + * Inert for the builders here — each is a pure, total pass over the file set — + * and the safer of the two behaviours if that ever stops being true. + */ +import type { ParsedFile } from 'gitnexus-shared'; + +export function perFileSet | readonly ParsedFile[], T extends object>( + build: (key: K) => T, +): (key: K) => T { + const cache = new WeakMap(); + return (key) => { + const cached = cache.get(key); + if (cached !== undefined) return cached; + const built = build(key); + cache.set(key, built); + return built; + }; +} diff --git a/gitnexus/src/core/ingestion/import-resolvers/php.ts b/gitnexus/src/core/ingestion/import-resolvers/php.ts index 303bf5546..72acba2f0 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/php.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/php.ts @@ -37,8 +37,8 @@ export function resolvePhpImportInternal( importPath: string, composerConfig: ComposerConfig | null, allFiles: Set, - normalizedFileList: string[], - allFileList: string[], + normalizedFileList: readonly string[], + allFileList: readonly string[], index?: SuffixIndex, ): string | null { // Normalize: replace backslashes with forward slashes @@ -49,13 +49,29 @@ export function resolvePhpImportInternal( if (composerConfig) { const sorted = getSortedPsr4(composerConfig); + const authoritativePsr4 = + composerConfig.authoritativePsr4 ?? new Set(sorted.map(([namespace]) => namespace)); + let matchedAuthoritativeNamespace = false; + let hasAuthoritativeCatchAllNamespace = false; + const ownershipPath = normalized.replace(/^\/+/, ''); + for (const [nsPrefix, dirPrefix] of sorted) { - const nsPrefixSlash = nsPrefix.replace(/\\/g, '/'); - if (normalized.startsWith(nsPrefixSlash + '/') || normalized === nsPrefixSlash) { - const remainder = normalized.slice(nsPrefixSlash.length).replace(/^\//, ''); + const nsPrefixSlash = nsPrefix.replace(/\\/g, '/').replace(/\/+$/, ''); + const isCatchAll = nsPrefixSlash === ''; + if ( + isCatchAll || + ownershipPath.startsWith(nsPrefixSlash + '/') || + ownershipPath === nsPrefixSlash + ) { + const isAuthoritative = authoritativePsr4.has(nsPrefix); + matchedAuthoritativeNamespace ||= isAuthoritative; + hasAuthoritativeCatchAllNamespace ||= isAuthoritative && isCatchAll; + const remainder = ownershipPath.slice(nsPrefixSlash.length).replace(/^\//, ''); // 1. Try class-style PSR-4: full path → file (e.g. App\Models\User → app/Models/User.php) - const filePath = dirPrefix + (remainder ? '/' + remainder : '') + '.php'; + const mappedPath = + dirPrefix === '' ? remainder : dirPrefix + (remainder ? '/' + remainder : ''); + const filePath = mappedPath + '.php'; if (allFiles.has(filePath)) return filePath; if (index) { const result = index.getInsensitive(filePath); @@ -64,28 +80,64 @@ export function resolvePhpImportInternal( // 2. Function/constant fallback: strip last segment (symbol name), scan namespace directory. // e.g. App\Models\getUser → directory app/Models/, find first .php file in that dir. - const lastSlash = remainder.lastIndexOf('/'); - const nsDir = lastSlash >= 0 ? dirPrefix + '/' + remainder.slice(0, lastSlash) : dirPrefix; + // A root/catch-all mapping cannot safely infer a symbol's declaring + // file from an arbitrary sibling. The higher-level PHP resolver has + // parsed symbol-kind and declaration evidence for function/const + // imports; class imports must not inherit this directory heuristic. + if (!isCatchAll && dirPrefix !== '') { + const lastSlash = remainder.lastIndexOf('/'); + const relativeNamespace = lastSlash >= 0 ? remainder.slice(0, lastSlash) : ''; + const nsDir = relativeNamespace === '' ? dirPrefix : `${dirPrefix}/${relativeNamespace}`; - // Prefer SuffixIndex directory lookup (O(log n + matches)) over linear scan - if (index) { - const candidates = index.getFilesInDir(nsDir, '.php'); - if (candidates.length > 0) return candidates[0]; - } - - // Fallback: linear scan (only when SuffixIndex unavailable) - const nsDirPrefix = nsDir.endsWith('/') ? nsDir : nsDir + '/'; - for (const f of allFiles) { - if ( - f.startsWith(nsDirPrefix) && - f.endsWith('.php') && - !f.slice(nsDirPrefix.length).includes('/') - ) { - return f; + // Prefer SuffixIndex directory lookup (O(log n + matches)) over linear scan. + // + // An EMPTY bucket is a final answer, not a miss to retry with the scan + // below — which is what the `else` restores, and what this comment + // always claimed. Re-scanning on empty was the last per-import + // workspace traversal left in PHP resolution after #2901: any `use` + // matching a PSR-4 prefix whose directory holds no direct `.php` child + // (`App\Legacy\Ghost`) paid a full pass, measured at 201 traversals for + // 200 imports. + // + // The bucket is a superset of what the scan can find, for BOTH index + // shapes that reach here. A root-anchored direct child `nsDir/.php` + // has its directory exactly equal to `nsDir`, and `nsDir` is always one + // of that directory's own suffixes — so the shared `dirMap` (keyed on + // every directory suffix) necessarily contains it, as does the + // root-anchored parity index `languages/php/import-target.ts` builds. + // Empty superset therefore implies empty scan, and control falls + // through to the next PSR-4 prefix exactly as before. + if (index) { + const candidates = index.getFilesInDir(nsDir, '.php'); + if (candidates.length > 0) return candidates[0]; + } else { + // Linear scan, only when a SuffixIndex is genuinely unavailable. + const nsDirPrefix = nsDir.endsWith('/') ? nsDir : nsDir + '/'; + for (const f of allFiles) { + if ( + f.startsWith(nsDirPrefix) && + f.endsWith('.php') && + !f.slice(nsDirPrefix.length).includes('/') + ) { + return f; + } + } } } } } + + // A non-empty PSR-4 map is authoritative for namespaces it does not own. + // Preserve the existing mapped-namespace fallback behavior; #2962 is the + // conservative external-namespace gate, not a rewrite of mapped lookup. + // A catch-all owns every namespace, so its misses remain authoritative. + if ( + authoritativePsr4.size > 0 && + !composerConfig.hasUnmodeledAutoload && + (!matchedAuthoritativeNamespace || hasAuthoritativeCatchAllNamespace) + ) { + return null; + } } // Fallback: suffix matching (works without composer.json) diff --git a/gitnexus/src/core/ingestion/import-resolvers/python-file-index.ts b/gitnexus/src/core/ingestion/import-resolvers/python-file-index.ts new file mode 100644 index 000000000..d624bb45a --- /dev/null +++ b/gitnexus/src/core/ingestion/import-resolvers/python-file-index.ts @@ -0,0 +1,371 @@ +/** + * The one per-file-set index behind Python import resolution, plus the two + * importer-chain memos that ride inside it. + * + * ## Why this is its own module + * + * Everything here is derived from `allFilePaths` and nothing here is specific + * to either CALLER, and there are two of them on opposite sides of a layer + * boundary: `import-resolvers/python.ts` resolves the single-segment bare tier + * and `languages/python/import-target.ts` resolves the dotted tiers. The second + * imports the first, so the index could not live in either without the other + * reaching back through a cycle — it used to live in `import-target.ts`, which + * is why the bare tier had no O(1) proof of absence and probed the whole + * ancestor chain for every `import os`. + * + * The shape is the one `workspace-file-index.ts` and `package-dir-index.ts` + * already use in this directory: an interface, one `perFileSet` builder, and + * query functions taking the index. + */ + +import { perFileSet } from './per-file-set.js'; + +/** + * The importer's ancestor directories, CLOSEST FIRST and excluding the + * workspace root — `["backend/routers", "backend"]` for `backend/routers/x.py` + * — memoized per importer DIRECTORY for the lifetime of the pass. + * + * This is the #2913 fix. Both consumers used to rebuild the chain inline, one + * `dirParts.slice(0, i).join('/')` per component, on EVERY import: a per-import + * cost proportional to the importer's path depth, and quadratic in characters, + * on a file index that is itself depth-free. Real Python layouts are deep + * (`src/pkg/sub/feature/impl/mod.py` is ordinary), so the resolver was 6.8x + * slower on a deep corpus than on a shallow one holding the file count fixed, + * where every other language sat between 1.0x and 3.4x. + * + * A directory's ancestors are a pure function of the directory, and a pass + * resolves many imports per file, so one entry serves every import issued from + * anywhere in that directory. + * + * ## Lifetime and memory + * + * The Map lives INSIDE the per-file-set index, so it is reclaimed with the file + * set it was reached through (`perFileSet` is a `WeakMap`): it cannot leak + * across passes or repos, and there is no invalidation rule to get wrong. It is + * filled lazily, so it holds one entry per directory that actually ISSUES a + * Python import, never one per file and never one per directory in the repo — + * the bound #2649 (kernel-scale OOM) asks for. Each entry's strings are + * `slice`s of the longest one, so a chain costs pointers rather than a copy of + * the path per component. + * + * The derived key is the importer's directory exactly as the old inline code + * computed it — `norm.split('/').slice(0, -1).join('/')`, which for a path + * without a separator is `''` (a root-level importer, whose chain is empty). + */ +/** + * The importer's own directory, normalized — the key BOTH per-directory memos + * below are stored under. + * + * One exported derivation rather than one per accessor: the two memos live in + * the same index and must agree on what "the importer's directory" is, and a + * caller that already holds the directory (the bare-import tier computes it for + * its own proximity check) should not pay for it twice. It was three copies of + * `replace / lastIndexOf / slice` across two modules before, byte-identical by + * inspection and by nothing else. + */ +export function importerDirOf(fromFile: string): string { + const norm = fromFile.replace(/\\/g, '/'); + const lastSlash = norm.lastIndexOf('/'); + return lastSlash === -1 ? '' : norm.slice(0, lastSlash); +} + +export function importerAncestors(index: PythonFileIndex, importerDir: string): readonly string[] { + const memoized = index.ancestorsByDir.get(importerDir); + if (memoized !== undefined) return memoized; + const built = buildImporterAncestors(importerDir); + index.ancestorsByDir.set(importerDir, built); + return built; +} + +/** + * `["a/b/c", "a/b", "a"]` for `a/b/c`. Empty components are dropped first, so + * an absolute `/a/b` yields `["a/b", "a"]` — matching the `filter(Boolean)` the + * two inline walks did, and with it the absolute-path gating pinned by + * `python-import-target-parity.test.ts` (PR #1918 review P3a). + */ +function buildImporterAncestors(importerDir: string): readonly string[] { + const chain: string[] = []; + const parts = importerDir.split('/').filter(Boolean); + if (parts.length === 0) return chain; + chain.push(parts.join('/')); + for (let i = 1; i < parts.length; i++) { + const child = chain[i - 1]; + chain.push(child.slice(0, child.lastIndexOf('/'))); + } + return chain; +} + +/** + * Per-file-set index for Python import resolution, memoized on the + * `allFilePaths` Set object (the same Set is passed for every import in a run, + * so the index is built once and reused). Replaces the per-import O(files) + * scans in `resolveAbsoluteFromFiles` (suffix match) and `hasRepoCandidate` + * (package-existence gate) with O(1)/O(bucket) lookups. + * + * - `normSet`: every file path, normalized to forward slashes (for the exact + * `f === rootFile|initFile` membership checks). It IS derivable from the two + * buckets below — both probes could be a `.some(c => c.norm === …)` over + * `byBasename.get(rootFile)` / `byInitParent.get(initFile)` — and it is kept + * anyway, deliberately. `byBasename` is keyed on the BASENAME, so its bucket + * for a common Python file name is not small and grows with the repo: on a + * 9 000-file service tree, `utils.py`, `models.py` and `views.py` hold 1 000 + * entries each. `import utils` would then scan every `utils.py` in the + * workspace on every import — a per-import cost proportional to corpus size, + * which is the exact defect class #2901/#2902/#2908 removed. The Set trades + * ~1.6 MB at 32 000 files, against a 6.4 MB reading, to keep both probes + * O(1). Do not "simplify" it away without re-measuring that bucket. + * - `byBasename`: last path component (e.g. `models.py`, `__init__.py`) -> + * all `{ raw, norm }` candidates, so suffix matches can be gathered from the + * relevant bucket and the exact tie-break applied across ALL of them. + * - `byInitParent`: `__init__.py` files keyed by their last TWO components + * (`/__init__.py`). The package suffix lookup (`pkg.sub` -> + * `…/sub/__init__.py`) targets only same-named package dirs via this map + * instead of scanning every `__init__.py` in the repo — the common + * multi-segment import path no longer scales with package count + * (PR #1918 review P2b). `__init__.py` files stay in `byBasename` too, for + * the rarer explicit `pkg.__init__` import that resolves via the module + * (`….py`) lookup. + * - `dirPrefixes`: every directory prefix of a `.py` file, trailing-slashed + * (`a/b/c.py` -> `a/`, `a/b/`), for "is there a .py file under `/`". + * - `nestedDirNames`: the NAME of every such directory that has a non-empty + * parent (`a/b/c.py` -> `b`, not `a`), which is exactly the set of segments + * `hasRepoCandidate`'s ancestor walk can ever match — so a segment absent + * from it settles the walk in one lookup (#2913). + * - `ancestorsByDir`: the per-importer-directory ancestor-chain memo behind + * `importerAncestors`. The one structure here that is NOT derived from the + * file set: it is filled lazily, from the importer paths the pass actually + * resolves against, and lives here so it dies with the pass. + * - `bareImportPrefixesByDir`: the same idea for the OTHER chain — the + * sys.path-style prefixes `resolvePythonImportInternal`'s single-segment + * walk probes. A different sequence, not a different spelling: see + * `importerBarePrefixes`. Two memos in one index rather than two indexes, + * because they are keyed on the same thing and must die together. + * + * Exported for `test/unit/scope-resolution/python/python-importer-ancestors.test.ts` + * and `test/unit/import-resolvers/python-importer-prefixes.test.ts`, which read + * the two memos after driving the production adapters. No counter ships for + * either — the Map IS the memo, and its SIZE is the assertion: one entry per + * importer directory, however many imports were resolved. Everything else about + * the index stays internal. + */ +export interface PythonFileIndex { + readonly normSet: Set; + readonly byBasename: Map; + readonly byInitParent: Map; + readonly dirPrefixes: Set; + readonly nestedDirNames: Set; + readonly ancestorsByDir: Map; + readonly bareImportPrefixesByDir: Map; +} + +export const getPythonFileIndex = perFileSet( + (allFilePaths: ReadonlySet): PythonFileIndex => { + // Runs on a cache miss only. That it happens once per run and not once per + // import is asserted by counting traversals of the Set itself, in + // `test/integration/python-import-index-reuse.test.ts` — the PR #1918 review + // P1 guard (#2909). + + const normSet = new Set(); + const byBasename = new Map(); + const byInitParent = new Map(); + const dirPrefixes = new Set(); + const nestedDirNames = new Set(); + + for (const raw of allFilePaths) { + const norm = raw.replace(/\\/g, '/'); + // Python import resolution only ever queries `.py` paths: module `.py` + // and package `/__init__.py` membership (normSet), `.py` / + // `__init__.py` basename buckets (byBasename), and `.py` directory prefixes + // (dirPrefixes). Non-`.py` files can never match any of those, so skip them + // — they were dead weight in every structure on polyglot monorepos + // (PR #1918 review P3b; dirPrefixes was already `.py`-gated). + if (!norm.endsWith('.py')) continue; + normSet.add(norm); + + // ONE entry object per file, shared by both buckets below: a package file + // lands in `byBasename` and `byInitParent`, and two literals for the same + // `(raw, norm)` pair cost ~40 B each on every `__init__.py`. + const entry = { raw, norm }; + + const lastSlash = norm.lastIndexOf('/'); + const base = lastSlash >= 0 ? norm.slice(lastSlash + 1) : norm; + // `set(base, [entry])` rather than `set(base, [])` then `push`: an empty + // array literal that is immediately pushed to makes V8 grow the backing + // store to its 16-slot minimum, so every bucket holding ONE file retains + // 15 empty pointer slots — 128 B — for the whole pass. `byBasename` has + // roughly one bucket per file, which made that the dominant term in this + // index: measured 5.50 MiB against 1.60 MiB for the one-element form at + // 32 000 `.py` paths, byte-identical contents. Same shape as + // `languages/php/import-target.ts`'s directory buckets. + const bucket = byBasename.get(base); + if (bucket === undefined) byBasename.set(base, [entry]); + else bucket.push(entry); + + // Package files also get a parent-keyed bucket so a `pkg.sub` lookup hits + // only `…/sub/__init__.py` candidates, not every `__init__.py` (P2b). + if (base === '__init__.py' && lastSlash >= 0) { + const dir = norm.slice(0, lastSlash); + const parentSlash = dir.lastIndexOf('/'); + const parentName = parentSlash >= 0 ? dir.slice(parentSlash + 1) : dir; + if (parentName) { + const initKey = `${parentName}/__init__.py`; + const ib = byInitParent.get(initKey); + if (ib === undefined) byInitParent.set(initKey, [entry]); + else ib.push(entry); + } + } + + // Directory prefixes: every slash-terminated prefix of the path (every + // index just past a '/', up to and including the file's own directory). + // Scanning the FULL normalized path — including any leading '/' for + // absolute paths — makes `dirPrefixes.has(X)` match exactly when the old + // gate's `f.startsWith(X)` (X always ends in '/') matched. The previous + // split+`filter(Boolean)` dropped the leading empty component, so an + // absolute file `/repo/svc/x.py` yielded `repo/svc/` (no leading slash) and + // gate-passed where `"/repo/svc/x.py".startsWith("repo/svc/")` is false + // (PR #1918 review P3a). For relative paths the set is identical. + // + // The walk runs from the DEEPEST prefix outward and stops at the first + // one already recorded. Every prefix is added together with all of its + // own ancestors, so a hit proves the rest of the chain is already there — + // which makes the second and later files of a directory cost ONE lookup + // instead of one insert per path component. This build was the last part + // of Python's resolution that still scaled with path depth (#2913): the + // same 400-file corpus moved sixteen directories down went from 800 + // inserts to 7200, for the same ~120 distinct prefixes. + // + // `nestedDirNames` rides the same walk. A directory prefix has the shape + // `//` — the only shape `hasRepoCandidate`'s check (3) + // probes — exactly when another slash precedes it at index > 0. Index 0 + // is excluded on purpose: `a/` and `/` name a directory whose parent is + // empty, which check (2) already answers and which the ancestor walk + // (non-empty ancestors only) never probes. + for (let i = lastSlash; i >= 0; i--) { + if (norm[i] !== '/') continue; + const dirPrefix = norm.slice(0, i + 1); + if (dirPrefixes.has(dirPrefix)) break; + dirPrefixes.add(dirPrefix); + const parentSlash = i > 0 ? norm.lastIndexOf('/', i - 1) : -1; + if (parentSlash > 0) nestedDirNames.add(norm.slice(parentSlash + 1, i)); + } + } + + return { + normSet, + byBasename, + byInitParent, + dirPrefixes, + nestedDirNames, + ancestorsByDir: new Map(), + bareImportPrefixesByDir: new Map(), + }; + }, +); + +/** + * The sys.path-style prefixes `resolvePythonImportInternal`'s single-segment + * bare-import walk probes, in order, for an importer sitting in `importerDir` — + * memoized per DIRECTORY for the lifetime of the pass, in the same index and + * for the same reasons as `importerAncestors`. + * + * ## Why this is not `ancestorsByDir` + * + * A DIFFERENT SEQUENCE, not a different spelling. For `backend/routers/cron.py`: + * + * importerAncestors ["backend/routers", "backend"] + * importerBarePrefixes ["backend/", ""] + * + * Three differences, each load-bearing: + * + * 1. `importerAncestors` opens with the importer's OWN directory; this walk + * does not, because its proximity check has already probed that directory. + * 2. This walk ENDS at the workspace root (`""`, which probes `.py` + * unprefixed); `importerAncestors` stops short of it, because + * `resolveAbsoluteFromFiles` probes the root before its walk instead. + * 3. `importerAncestors` drops empty components (`filter(Boolean)`); this walk + * keeps them, and the difference decides real resolutions — for + * `/abs/a/b/mod.py` this walk probes `/abs/a/`, `/abs/`, `""`, `""` where a + * filtered chain would probe `abs/a/b/`, `abs/a/`, `abs/`, none of which is + * a prefix of any file in an absolute-path workspace. + * + * So the two cannot share one chain without changing which files resolve. They + * do share the index, the key and the lifetime, which is what actually matters + * for #2649: both are filled lazily, hold one entry per directory that ISSUES + * an import, and die with the pass because the index does. + */ +export function importerBarePrefixes( + index: PythonFileIndex, + importerDir: string, +): readonly string[] { + const memoized = index.bareImportPrefixesByDir.get(importerDir); + if (memoized !== undefined) return memoized; + const built = buildImporterBarePrefixes(importerDir); + index.bareImportPrefixesByDir.set(importerDir, built); + return built; +} + +/** + * `["a/b/", "a/", ""]` for `a/b/c` — every proper ancestor of `importerDir`, + * closest first, slash-terminated, ending at the workspace root. + * + * Cutting the string at each `lastIndexOf('/')` walks the same ancestors the + * pre-#2913-followup `dirParts.slice(0, i).join('/')` produced, INCLUDING the + * empty components a `filter(Boolean)` would have dropped: `/abs/a/b` yields + * `["/abs/a/", "/abs/", "", ""]`, the second `""` being the `i === 0` step that + * followed the leading empty component. Byte-identical sequences, duplicates + * kept, so the probes this feeds are unchanged in content, order and count. + */ +function buildImporterBarePrefixes(importerDir: string): readonly string[] { + const prefixes: string[] = []; + let dir = importerDir; + let slash = dir.lastIndexOf('/'); + while (slash !== -1) { + dir = dir.slice(0, slash); + prefixes.push(dir === '' ? '' : `${dir}/`); + slash = dir.lastIndexOf('/'); + } + prefixes.push(''); + return prefixes; +} + +/** + * "No file anywhere in the workspace can be `/.py` or + * `//__init__.py`, for ANY prefix ``" — in two Map lookups. + * + * This is a PROOF OF ABSENCE, not a heuristic filter, and it is what lets the + * single-segment bare walk skip itself entirely. Both shapes it rules out are + * the only two shapes that walk probes: a probe `${prefix}${segment}.py` that + * is a member of the file set is a path with no backslash (the prefix comes + * from a normalized importer and the guard below rejects a segment carrying + * one), so it equals its own normalized form and its basename is exactly + * `${segment}.py` — which puts it in `byBasename`. A probe + * `${prefix}${segment}/__init__.py` that is a member likewise has parent + * directory name exactly `segment`, non-empty, which puts it in `byInitParent` + * whether or not `prefix` is empty. So a miss in both buckets means every probe + * the walk would issue is guaranteed to miss. + * + * Two inputs cannot be proven absent and get `false` — walk as before: + * + * - the EMPTY segment (a target spelled with a trailing dot). + * `byInitParent` skips `__init__.py` files whose parent directory name is + * empty, so its absence proves nothing. Same carve-out + * `resolveAbsoluteFromFiles` makes for `lastSeg === ''`. + * - a segment containing a BACKSLASH. The buckets are keyed on normalized + * paths, so a raw `a\b.py` is filed under basename `b.py`; a probe for the + * segment `a\b` would look up `a\b.py`, miss, and wrongly conclude absence + * while `allFilePaths.has('a\\b.py')` is true. Not reachable from a Python + * import statement, but this function is a proof and a proof has no + * unstated preconditions. + * + * The dotted tier in `languages/python/import-target.ts` asks the same question + * of the same two buckets and is deliberately NOT routed through here: it needs + * the candidate ARRAYS for its suffix fallback, so it does the two `get`s it + * already needs and derives the answer, rather than paying two extra `has` + * lookups per import to share four lines. + */ +export function pythonSegmentAbsent(index: PythonFileIndex, segment: string): boolean { + if (segment === '' || segment.includes('\\')) return false; + if (index.byBasename.has(`${segment}.py`)) return false; + if (index.byInitParent.has(`${segment}/__init__.py`)) return false; + return true; +} diff --git a/gitnexus/src/core/ingestion/import-resolvers/python.ts b/gitnexus/src/core/ingestion/import-resolvers/python.ts index 2de11cc55..9914a7613 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/python.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/python.ts @@ -6,6 +6,12 @@ * This file contains the shared internal helper used by the strategy and tests. */ +import { + getPythonFileIndex, + importerBarePrefixes, + importerDirOf, + pythonSegmentAbsent, +} from './python-file-index.js'; import { tryResolveWithExtensions } from './utils.js'; /** @@ -51,8 +57,24 @@ export function resolvePythonImportInternal( const pathLike = importPath.replace(/\./g, '/'); if (pathLike.includes('/')) return null; - // Normalize for Windows backslashes - const importerDir = currentFile.replace(/\\/g, '/').split('/').slice(0, -1).join('/'); + // O(1) proof of absence, before any probing. Every probe below — the two + // proximity probes and the two per ancestor step — has the shape + // `/.py` or `//__init__.py`, and + // `pythonSegmentAbsent` answers "no file in the workspace has EITHER shape, + // for any prefix" in two Map lookups on the index the dotted tiers already + // build. That is `true` for `os`, `sys`, `django` and every other + // distribution the repo does not vendor — i.e. for most imports in most + // Python repos — and it retires the whole walk for them instead of running + // it to the workspace root. It is exact, not a filter: a miss here means + // every probe the walk would have issued was guaranteed to miss. + const index = getPythonFileIndex(allFiles); + if (pythonSegmentAbsent(index, pathLike)) return null; + + // One derivation, shared with the index's other per-directory memo — see + // `importerDirOf`. It replaced `split('/').slice(0, -1).join('/')`: identical + // for every input (a path with no separator has no directory, which is `''` + // both ways) without the per-import array of one element per path component. + const importerDir = importerDirOf(currentFile); // Proximity check — only applies when the importer lives in a subdirectory. // Root-level importers (importerDir === '') skip straight to the ancestor @@ -68,10 +90,12 @@ export function resolvePythonImportInternal( // importer's directory to find the module in an ancestor, preferring the closest match. // This prevents cross-language misresolution (e.g., Python `from middleware import X` // resolving to a TypeScript middleware.ts via suffix matching). Issue #417. - const dirParts = importerDir.split('/'); - for (let i = dirParts.length - 1; i >= 0; i--) { - const ancestorDir = dirParts.slice(0, i).join('/'); - const prefix = ancestorDir ? `${ancestorDir}/` : ''; + // + // The prefixes come from `importerBarePrefixes`, built ONCE per importer + // directory per pass and stored in the same index consulted above. Rebuilding + // them here — `dirParts.slice(0, i).join('/')`, one array and one string per + // path component — was the last per-import ancestor walk left after #2913. + for (const prefix of importerBarePrefixes(index, importerDir)) { if (allFiles.has(`${prefix}${pathLike}/__init__.py`)) return `${prefix}${pathLike}/__init__.py`; if (allFiles.has(`${prefix}${pathLike}.py`)) return `${prefix}${pathLike}.py`; } diff --git a/gitnexus/src/core/ingestion/import-resolvers/ruby.ts b/gitnexus/src/core/ingestion/import-resolvers/ruby.ts index 4bf47d31f..b19a6b3c3 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/ruby.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/ruby.ts @@ -16,8 +16,8 @@ import { suffixResolve } from './utils.js'; */ export function resolveRubyImportInternal( importPath: string, - normalizedFileList: string[], - allFileList: string[], + normalizedFileList: readonly string[], + allFileList: readonly string[], index?: SuffixIndex, ): string | null { const pathParts = importPath.replace(/^\.\//, '').split('/').filter(Boolean); diff --git a/gitnexus/src/core/ingestion/import-resolvers/standard.ts b/gitnexus/src/core/ingestion/import-resolvers/standard.ts index 888e80208..4cc4c1c60 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/standard.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/standard.ts @@ -29,8 +29,8 @@ export const resolveImportPath = ( currentFile: string, importPath: string, allFiles: Set, - allFileList: string[], - normalizedFileList: string[], + allFileList: readonly string[], + normalizedFileList: readonly string[], resolveCache: Map, language: SupportedLanguages, tsconfigPaths: TsconfigPaths | null, diff --git a/gitnexus/src/core/ingestion/import-resolvers/types.ts b/gitnexus/src/core/ingestion/import-resolvers/types.ts index 864206fc3..081972b77 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/types.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/types.ts @@ -4,14 +4,7 @@ * Extracted from import-resolution.ts to co-locate types with their consumers. */ -import type { - TsconfigPaths, - GoModuleConfig, - CSharpProjectConfig, - CSharpNamespaceEvidence, - ComposerConfig, -} from '../language-config.js'; -import type { SwiftPackageConfig } from '../language-config.js'; +import type { ImportConfigs } from '../language-config.js'; import type { SuffixIndex } from './utils.js'; import type { SupportedLanguages } from 'gitnexus-shared'; @@ -26,17 +19,6 @@ export type ImportResult = | { kind: 'package'; files: string[]; dirSuffix: string } | null; -/** Bundled language-specific configs loaded once per ingestion run. */ -export interface ImportConfigs { - tsconfigPaths: TsconfigPaths | null; - goModule: GoModuleConfig | null; - composerConfig: ComposerConfig | null; - swiftPackageConfig: SwiftPackageConfig | null; - csharpConfigs: CSharpProjectConfig[]; - /** In-repo namespace evidence gating C# suffix-fallback resolution (#1881). */ - csharpNamespaces?: CSharpNamespaceEvidence; -} - /** Pre-built lookup structures for import resolution. Build once, reuse across chunks. */ export interface ImportResolutionContext { allFilePaths: Set; diff --git a/gitnexus/src/core/ingestion/import-resolvers/utils.ts b/gitnexus/src/core/ingestion/import-resolvers/utils.ts index 6a033c1ee..5dec720bc 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/utils.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/utils.ts @@ -79,66 +79,304 @@ export function tryResolveWithExtensions( * etc. */ export interface SuffixIndex { - /** Exact suffix lookup (case-sensitive) */ + /** + * Exact suffix lookup (case-sensitive). + * + * The map behind this is built on the FIRST call and memoized — see + * `buildSuffixIndex`. All three maps are deferred; a consumer pays only for + * the questions it actually asks. + */ get(suffix: string): string | undefined; - /** Case-insensitive suffix lookup */ + /** + * Case-insensitive suffix lookup. + * + * Deferred like `get`, and — when `get` was asked first — DERIVED from that + * map rather than traversed for a second time. See `buildSuffixIndex`. + */ getInsensitive(suffix: string): string | undefined; - /** Get all files in a directory suffix */ - getFilesInDir(dirSuffix: string, extension: string): string[]; + /** + * Get all files in a directory suffix. + * + * `dirSuffix` is matched as a SEGMENT-aligned directory suffix — every + * returned file is a direct child of a directory `D` with + * `D === dirSuffix || D.endsWith('/' + dirSuffix)`. Callers may rely on this + * and skip a direct-child re-check; `import-resolvers/csharp.ts` step 2 does + * exactly that. It bounds what may be RETURNED, not what must be found: an + * implementation is free to answer with fewer files, and the root-anchored + * index in `languages/php/import-target.ts` answers only the `D === dirSuffix` + * arm. + * + * `readonly` is the CONTRACT, and it is the contract for every implementation + * of this interface, not a description of any one of them: an implementation + * is free to return its own bucket by reference, so callers must treat the + * result as shared and never `sort`/`splice` it in place. The compiler now + * refuses that at the call site. Whether a given implementation shares or + * copies is its own business and documented where it is built — + * `buildSuffixIndex` shares, the root-anchored parity index in + * `languages/php/import-target.ts` returns a filtered copy. + * + * Implementations that memoize should note the directory map behind this may + * be built on the FIRST call rather than up front, so a caller that never + * asks a directory question never pays for it — see `buildSuffixIndex`. + */ + getFilesInDir(dirSuffix: string, extension: string): readonly string[]; } -export function buildSuffixIndex(normalizedFileList: string[], allFileList: string[]): SuffixIndex { - // Map: normalized suffix -> original file path - const exactMap = new Map(); - // Map: lowercase suffix -> original file path - const lowerMap = new Map(); - // Map: directory suffix -> list of file paths in that directory - const dirMap = new Map(); +export interface SuffixIndexOptions { + /** + * Promise from the caller that `normalizedFileList[i] === normalizedFileList[i].toLowerCase()` + * for every `i` — i.e. the "normalized" list is a LOWERCASED file list, not + * merely a slash-normalized one. + * + * `import-resolvers/pass-cache.ts` is the one caller that can make it: it + * builds `normalizedFileList` as `allFileList.map((f) => f.toLowerCase())`. + * Every suffix of an all-lowercase path is itself lowercase, so + * `suffix.toLowerCase() === suffix` and the case-folded map came out a + * byte-identical copy of the exact one — same keys, same values, same + * insertion order. Measured 14.00 MiB at 32 000 paths, 29.8% of the retained + * `ImportPassCache` — and one `ImportPassCache` is built per ts-family + * adapter per pass, so the waste was carried once for each of them. + * + * With this set, `getInsensitive` reads the exact map directly instead. It is + * the same map the derivation below would have produced, so this is a skipped + * copy and not a second lookup rule — see `getLowerMap`. + * + * Setting it over a list that is NOT all-lowercase is a behaviour change, not + * an optimization: `getInsensitive` would then answer case-sensitively. + */ + readonly alreadyLowercased?: boolean; +} - for (let i = 0; i < normalizedFileList.length; i++) { - const normalized = normalizedFileList[i]; - const original = allFileList[i]; - const parts = normalized.split('/'); +export function buildSuffixIndex( + normalizedFileList: readonly string[], + allFileList: readonly string[], + options?: SuffixIndexOptions, +): SuffixIndex { + const alreadyLowercased = options?.alreadyLowercased === true; - // Index all suffixes: "a/b/c.java" -> ["c.java", "b/c.java", "a/b/c.java"] - for (let j = parts.length - 1; j >= 0; j--) { - const suffix = parts.slice(j).join('/'); - // Only store first match (longest path wins for ambiguous suffixes) - if (!exactMap.has(suffix)) { - exactMap.set(suffix, original); + /** + * Map: normalized suffix -> original file path. + * + * DEFERRED, like `dirMap` below and for the same reason (#2903 extended to + * the two suffix maps). Several consumers on the ScopeResolver path ask only + * ONE of the two suffix questions and were paying for both: + * + * - `languages/java/import-target.ts` and the no-csproj leg of + * `languages/csharp/import-target.ts` call `get` and never + * `getInsensitive` — measured 49.98 MiB dead of a 100.82 MiB Java index + * at 32 000 paths (49.6%), against a gated ceiling of 146.9 MiB; + * - `languages/php/import-target.ts` calls `getInsensitive` and never `get` + * — 34.49 MiB of 69.85 MiB (49.4%). + * + * Ruby, the csproj leg of C#, `group/extractors/include-extractor.ts` and + * `suffixResolve` below read both, and all four read `get` FIRST (they are + * written `get(s) || getInsensitive(s)`), which is what makes the derivation + * in `getLowerMap` the cheap order rather than the expensive one. + */ + let exactMap: Map | null = null; + + const getExactMap = (): Map => { + if (exactMap !== null) return exactMap; + const built = new Map(); + for (let i = 0; i < normalizedFileList.length; i++) { + const normalized = normalizedFileList[i]; + const original = allFileList[i]; + + // Index all suffixes: "a/b/c.java" -> ["c.java", "b/c.java", "a/b/c.java"]. + // + // Walked as slash offsets into `normalized` rather than as + // `normalized.split('/')` + `parts.slice(j).join('/')`: the slice of the + // ORIGINAL string is byte-identical to the re-joined parts (no separator + // is invented or dropped — verified over 361 865 suffix strings including + // leading, doubled and trailing slashes), and it allocates one string + // instead of a parts array, a slice array and a joined string per suffix. + // Measured 357.4 ms -> 264.5 ms at 32 000 paths. + let slash = normalized.lastIndexOf('/'); + while (slash >= 0) { + const suffix = normalized.slice(slash + 1); + // Only store first match (longest path wins for ambiguous suffixes) + if (!built.has(suffix)) built.set(suffix, original); + // A path may begin with '/', whose suffix is the whole string below. + if (slash === 0) break; + slash = normalized.lastIndexOf('/', slash - 1); } - const lower = suffix.toLowerCase(); - if (!lowerMap.has(lower)) { - lowerMap.set(lower, original); + // j = 0 — the whole path, which the slash walk cannot emit. + if (!built.has(normalized)) built.set(normalized, original); + } + exactMap = built; + return built; + }; + + /** + * Map: lowercase suffix -> original file path. + * + * Deferred, and when the exact map already exists DERIVED from it instead of + * traversed for: one pass over that map's DISTINCT keys rather than a second + * pass over every (file × depth) suffix. Measured 330.3 ms total (200.6 build + * + 129.7 derive) against 388.8 ms for the single fused traversal that built + * both eagerly — so the two-map consumers get cheaper too, which per-map + * laziness on its own does not (407.1 ms, a second full traversal). + * + * The derivation is EQUAL, not approximate, and the argument is short. Let + * the fused loop's global order be the pairs (suffix, file) it visited. For a + * lowercase key L, let p be the first position whose suffix lowercases to L — + * the entry today's `lowerMap` keeps. Nothing before p carries that suffix + * spelled ANY way, so p is also the first occurrence of its exact spelling + * and is therefore in the exact map, holding that same file. Exact-map + * insertion order is by first-occurrence position, so among the exact keys + * folding to L, p's is reached first and first-wins keeps it. Insertion order + * of the derived map is the order of those p's, which is the order today's + * `lowerMap` inserts L. Verified rather than only argued: byte-equal keys, + * values and order over 968 418 entries across bench-shaped, PascalCase, + * case-colliding, deep-monorepo, Unicode-adversarial and 400 seeded-fuzz + * corpora. + * + * When `getInsensitive` is asked FIRST (PHP), there is nothing to derive + * from, so it is built straight — one traversal, one map, which is the point. + * Asking `get` afterwards would then cost the second traversal; no consumer + * does, and the fallback stays correct if one ever starts. + */ + let lowerMap: Map | null = null; + + const getLowerMap = (): Map => { + // Over an already-lowercased file list the derivation is the identity, so + // the exact map IS the case-folded map. Skip the copy. + if (alreadyLowercased) return getExactMap(); + if (lowerMap !== null) return lowerMap; + + const built = new Map(); + if (exactMap !== null) { + for (const [suffix, original] of exactMap) { + const lower = suffix.toLowerCase(); + if (!built.has(lower)) built.set(lower, original); } + lowerMap = built; + return built; } - // Index directory membership - const lastSlash = normalized.lastIndexOf('/'); - if (lastSlash >= 0) { - // Build all directory suffixes - const dirParts = parts.slice(0, -1); - const fileName = parts[parts.length - 1]; - const ext = fileName.substring(fileName.lastIndexOf('.')); + for (let i = 0; i < normalizedFileList.length; i++) { + const normalized = normalizedFileList[i]; + const original = allFileList[i]; + let slash = normalized.lastIndexOf('/'); + while (slash >= 0) { + const lower = normalized.slice(slash + 1).toLowerCase(); + if (!built.has(lower)) built.set(lower, original); + if (slash === 0) break; + slash = normalized.lastIndexOf('/', slash - 1); + } + const whole = normalized.toLowerCase(); + if (!built.has(whole)) built.set(whole, original); + } + lowerMap = built; + return built; + }; - for (let j = dirParts.length - 1; j >= 0; j--) { - const dirSuffix = dirParts.slice(j).join('/'); - const key = `${dirSuffix}:${ext}`; - let list = dirMap.get(key); + /** + * Map: `${directory suffix}:${extension}` -> file paths in that directory. + * + * DEFERRED, not dropped (#2903). This is the array-valued map of the three + * and by far the most expensive: one entry — and one array push — per file + * per directory component, so O(files × depth) in entries AND in array + * churn. Measured on the 32k-path arms of `bench/import-target/`, it is + * ~15% of the retained C# index and ~19% of the retained Ruby one. + * + * Only `getFilesInDir` reads it, and only four call sites reach that: + * `import-resolvers/{php,csharp,jvm}.ts` and `import-resolvers/configs/ + * python.ts`. Every other consumer of this index — `workspace-file-index.ts` + * serving Ruby, `languages/typescript/scope-resolver.ts`, + * `languages/vue/import-target.ts`, `group/extractors/include-extractor.ts` + * — asks only suffix questions and was paying the whole footprint for a map + * it never touched. Since these indexes are now retained for a whole + * resolution pass rather than rebuilt per import (#2877-#2880), that is + * retained memory against the #2649 kernel-scale OOM constraint. + * + * `null` until the first `getFilesInDir`; the MAP is memoized, not the + * decision to build it, so a repeated miss cannot rebuild it. Building it + * later is behaviour-identical because it is a pure function of + * `normalizedFileList` / `allFileList`, and it retains nothing new: every + * production caller already holds both arrays alive alongside the index + * (`WorkspaceFileIndex.normalized`/`.all`, the TS and Vue `PassCache`s, + * `IncludeExtractor.extract`'s locals). + */ + let dirMap: Map | null = null; + + const getDirMap = (): Map => { + if (dirMap !== null) return dirMap; + const built = new Map(); + for (let i = 0; i < normalizedFileList.length; i++) { + const normalized = normalizedFileList[i]; + const original = allFileList[i]; + const lastSlash = normalized.lastIndexOf('/'); + // A file at the repo root is in no directory suffix. + if (lastSlash < 0) continue; + + // The file name from its last '.', or the WHOLE file name when it carries + // none — `substring(-1)` clamps to 0, which is what the `parts` form + // (`fileName.substring(fileName.lastIndexOf('.'))`) spelled. A '.' in a + // DIRECTORY is not an extension, hence `dot > lastSlash` rather than + // `dot >= 0`. + const dot = normalized.lastIndexOf('.'); + const ext = dot > lastSlash ? normalized.slice(dot) : normalized.slice(lastSlash + 1); + + // Every directory suffix of `normalized.slice(0, lastSlash)`, shortest + // first — the order `for (j = dirParts.length - 1; j >= 0; j--)` emitted, + // and load-bearing: `php.ts` returns `candidates[0]` of a bucket, so a + // reordered bucket is a behaviour change, not a wash. + // + // Walked as slash offsets into `normalized`, the same rewrite `getExactMap` + // above documents and for the same reason — a slice of the ORIGINAL string + // is byte-identical to the re-joined parts, and it allocates one string per + // suffix instead of a parts array, a slice array and a joined string per + // suffix. This is the map where it pays most: one entry, one array push AND + // one key per file per directory component, the "by far the most expensive" + // of the three. Measured 226.9 ms -> 173.1 ms at 32 000 paths averaging + // ~10 directory components (min of 9, both loops alternating in one + // process). Verified rather than argued, over a 32 000-path corpus + // carrying absolute paths, doubled separators (`a//b`), backslash paths, + // root-level and extensionless files, dotted directories and trailing + // separators: 272 956 keys and 329 361 bucket entries came out with + // identical key sets in identical INSERTION order and identical buckets + // element-for-element, and 767 732 probes of the built index — every + // emitted (directory, extension) pair plus a wrong-extension and a + // one-level-deeper miss for each — answered exactly as the `parts` form's + // map did. 0 differences. + // + // `slash < 0` is the whole directory, which no slash search can emit and + // the only suffix a one-component directory has. + let start = lastSlash; + while (start >= 0) { + const slash = start > 0 ? normalized.lastIndexOf('/', start - 1) : -1; + const key = `${normalized.slice(slash + 1, lastSlash)}:${ext}`; + let list = built.get(key); if (!list) { list = []; - dirMap.set(key, list); + built.set(key, list); } list.push(original); + start = slash; } } - } + dirMap = built; + return built; + }; return { - get: (suffix: string) => exactMap.get(suffix), - getInsensitive: (suffix: string) => lowerMap.get(suffix.toLowerCase()), + get: (suffix: string) => getExactMap().get(suffix), + getInsensitive: (suffix: string) => getLowerMap().get(suffix.toLowerCase()), + // THIS implementation shares: it hands back `dirMap`'s own bucket rather + // than a copy. The map is built on first query and then held for the whole + // pass, so the window in which a mutating caller could corrupt later + // imports is the whole pass — which is why the interface makes the result + // `readonly` and the compiler refuses the mutation at the call site. + // + // Sharing beats copying because no caller keeps the array: two only measure + // it and two build a fresh array from it, so a defensive copy would + // allocate a whole bucket per import on the path this index exists to keep + // flat. `package-dir-index.ts` reached the same conclusion the same way — + // read-only containers, plus one copy where a bucket genuinely LEAVES + // (`sortedRootFiles`), which is the case `configs/swift.ts` is in. getFilesInDir: (dirSuffix: string, extension: string) => { - return dirMap.get(`${dirSuffix}:${extension}`) || []; + return getDirMap().get(`${dirSuffix}:${extension}`) || []; }, }; } @@ -148,8 +386,8 @@ export function buildSuffixIndex(normalizedFileList: string[], allFileList: stri */ export function suffixResolve( pathParts: string[], - normalizedFileList: string[], - allFileList: string[], + normalizedFileList: readonly string[], + allFileList: readonly string[], index?: SuffixIndex, ): string | null { if (index) { diff --git a/gitnexus/src/core/ingestion/import-resolvers/workspace-file-index.ts b/gitnexus/src/core/ingestion/import-resolvers/workspace-file-index.ts new file mode 100644 index 000000000..ad5c882ec --- /dev/null +++ b/gitnexus/src/core/ingestion/import-resolvers/workspace-file-index.ts @@ -0,0 +1,94 @@ +/** + * Per-file-set workspace index for the import-target resolvers that need the + * shared `SuffixIndex` (C#, Java, PHP, Ruby). + * + * The scope-resolution orchestrator passes the SAME `allFilePaths` Set object to + * every `resolveImportTarget` call in a pass (`pipeline/run.ts` builds it once), + * so memoizing on the Set's identity in a `WeakMap` turns the per-import + * "materialize two arrays + build a suffix index" cost into a one-time build. + * + * IMPORTANT for callers: the Set must be passed THROUGH, never copied. A + * defensive `new Set(allFilePaths)` in an adapter hands a fresh `WeakMap` key + * per call and silently restores the O(imports × files) behaviour — the exact + * bug PR #1918 shipped and had to fix in review (P1). + * + * Three layers guard that, and they guard different things: + * - ADAPTER BOUNDARY, where the defensive-copy hazard actually lives: + * `test/integration/*-import-index-reuse.test.ts` resolves through + * `ScopeResolver.resolveImportTarget` — the orchestrator adapter — and + * pins the EXACT number of times a run traverses the file set, one file per + * covered language over that language's own corpus. (The expected count is + * per language and legitimately differs: it is however many times the + * adapter derives something from the Set — two indexes, or an index plus the + * mutable copy the ts-family context wants.) All of them count traversals + * of a `CountingSet` (`test/helpers/counting-file-set.ts`): one instrument, + * no production surface, and it catches both the per-import rebuild and a + * scan reintroduced beside a reused index (#2909). + * - EVERY REGISTERED LANGUAGE, at the same boundary but as one property rather + * than one corpus per language: `test/unit/scope-resolution/import-target-index-reuse.contract.test.ts` + * drives each entry of `SCOPE_RESOLVERS` and asserts the traversal count for + * many imports equals the count for two. A new language cannot skip it, and + * the enforcement is a test rather than a roster anyone maintains: that + * file's inventory arm compares `SCOPE_RESOLVERS`' keys against its own + * fixture table and fails on a registered resolver that has neither a + * fixture nor an exemption, and its next arm pins the exemption map empty. + * - RESOLVER LEVEL: `test/unit/scope-resolution/import-target-index-parity.test.ts` + * calls the resolvers directly, so it never crosses the adapter boundary and + * a copy there leaves it green. What it catches is a rescan reintroduced + * INSIDE a resolver, by counting how many times the Set is iterated. + */ + +import { perFileSet } from './per-file-set.js'; +import { buildSuffixIndex, type SuffixIndex } from './utils.js'; + +/** + * `normalized` and `all` are `readonly string[]`, and — like + * `SuffixIndex.getFilesInDir` — that is the CONTRACT rather than a description + * of the arrays: they are built once and then held for the whole pass, so an + * in-place `sort`/`splice`/`reverse` would corrupt every later import in that + * pass, and these two are the largest shared arrays here (one element per file, + * read by C#, Java, PHP and Ruby). `readonly` on the field is what makes the + * compiler refuse the mutation at the call site instead of leaving it to a + * comment. `ImportPassCache` (`pass-cache.ts`) states the same contract the + * same way for the ts-family lists. + * + * The positional pairing is load-bearing too and depends on it: `csharp.ts` + * caches POSITIONS into `normalized` and reads the answer out of `all`, so a + * reordering of either array alone silently re-points every cached position. + */ +export interface WorkspaceFileIndex { + /** Every path, backslashes normalized to `/`. Parallel to `all`. */ + readonly normalized: readonly string[]; + /** Every path, exactly as it appears in the Set. Parallel to `normalized`. */ + readonly all: readonly string[]; + /** Segment-suffix → first file (in Set iteration order) carrying that suffix. */ + readonly index: SuffixIndex; + /** + * Normalized path → first raw path that normalizes to it. Answers "is there a + * file whose WHOLE path is X", which `index.get(X)` cannot: the suffix map + * conflates a whole-path hit with a `…/X` suffix hit, and C#'s + * `resolveDirectMatch` lets a whole-path match win over an earlier suffix + * match. + */ + readonly normToRaw: Map; +} + +export const getWorkspaceFileIndex = perFileSet( + (allFilePaths: ReadonlySet): WorkspaceFileIndex => { + const all = [...allFilePaths]; + const normalized = all.map((f) => f.replace(/\\/g, '/')); + const normToRaw = new Map(); + for (let i = 0; i < normalized.length; i++) { + // First wins, mirroring the `for (const raw of allFilePaths)` scans this + // replaces: they returned on the first match in iteration order. + if (!normToRaw.has(normalized[i])) normToRaw.set(normalized[i], all[i]); + } + + return { + normalized, + all, + index: buildSuffixIndex(normalized, all), + normToRaw, + }; + }, +); diff --git a/gitnexus/src/core/ingestion/language-config.ts b/gitnexus/src/core/ingestion/language-config.ts index 16ad25e43..11d50a9b0 100644 --- a/gitnexus/src/core/ingestion/language-config.ts +++ b/gitnexus/src/core/ingestion/language-config.ts @@ -2,11 +2,11 @@ import fs from 'fs/promises'; import { createReadStream } from 'fs'; import { createInterface } from 'readline'; import path from 'path'; -import type { ImportConfigs } from './import-resolvers/types.js'; import type { CsharpStructureLineScanner } from './languages/csharp/namespace-siblings.js'; import { isDev } from './utils/env.js'; +import { mapConcurrent } from '../../lib/utils.js'; import { logger } from '../logger.js'; // ============================================================================ // LANGUAGE-SPECIFIC CONFIG TYPES @@ -30,11 +30,103 @@ export interface GoModuleConfig { export interface ComposerConfig { /** Map of namespace prefix -> directory (e.g., "App\\" -> "app/") */ psr4: Map; + /** Production `autoload.psr-4` prefixes that may gate external namespaces. + * Absent on legacy/manual configs, where every mapping remains authoritative. */ + authoritativePsr4?: ReadonlySet; + /** True when Composer also declares an autoload mechanism this resolver does not model. */ + hasUnmodeledAutoload?: boolean; /** PSR-4 entries sorted by namespace length descending (longest match wins). * Cached once at config load time to avoid re-sorting on every import. */ psr4Sorted?: readonly [string, string][]; } +function normalizeComposerDirectory(baseDir: string, directory: string): string { + const normalizedBase = baseDir.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, ''); + const normalizedDirectory = directory + .replace(/\\/g, '/') + .replace(/^(?:\.\/)+/, '') + .replace(/\/+$/, ''); + if (normalizedBase === '') return normalizedDirectory; + if (normalizedDirectory === '') return normalizedBase; + return path.posix.normalize(`${normalizedBase}/${normalizedDirectory}`); +} + +/** Parse one Composer manifest without performing I/O. */ +export function parseComposerConfig(value: unknown, baseDir = ''): ComposerConfig | null { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return null; + + const composer = value as Record; + const autoload = composer.autoload; + const autoloadDev = composer['autoload-dev']; + if (autoload === undefined && autoloadDev === undefined) return null; + + const psr4 = new Map(); + const authoritativePsr4 = new Set(); + let hasUnmodeledAutoload = false; + + const addSection = (sectionValue: unknown, authoritative: boolean): void => { + if (typeof sectionValue !== 'object' || sectionValue === null || Array.isArray(sectionValue)) { + return; + } + const section = sectionValue as Record; + if ('psr-0' in section || 'classmap' in section) hasUnmodeledAutoload = true; + + const rawPsr4 = section['psr-4']; + if (typeof rawPsr4 !== 'object' || rawPsr4 === null || Array.isArray(rawPsr4)) return; + + for (const [namespace, directories] of Object.entries(rawPsr4)) { + const stringDirectories = Array.isArray(directories) + ? directories.filter((entry): entry is string => typeof entry === 'string') + : typeof directories === 'string' + ? [directories] + : []; + if (stringDirectories.length === 0) continue; + if (stringDirectories.length > 1) hasUnmodeledAutoload = true; + + const normalizedNamespace = namespace.replace(/\\+$/, ''); + const normalizedDirectory = normalizeComposerDirectory(baseDir, stringDirectories[0]); + const existing = psr4.get(normalizedNamespace); + if (existing !== undefined && existing !== normalizedDirectory) { + hasUnmodeledAutoload = true; + continue; + } + if (existing === undefined) psr4.set(normalizedNamespace, normalizedDirectory); + if (authoritative) authoritativePsr4.add(normalizedNamespace); + } + }; + + // Production mappings win duplicate prefixes. Development mappings remain + // usable for test code but do not establish authority for the external gate. + addSection(autoload, true); + addSection(autoloadDev, false); + + return { psr4, authoritativePsr4, hasUnmodeledAutoload }; +} + +/** Merge package-local Composer manifests into one repository-relative config. */ +export function mergeComposerConfigs(configs: readonly ComposerConfig[]): ComposerConfig | null { + if (configs.length === 0) return null; + + const psr4 = new Map(); + const authoritativePsr4 = new Set(); + let hasUnmodeledAutoload = false; + for (const config of configs) { + hasUnmodeledAutoload ||= config.hasUnmodeledAutoload === true; + for (const [namespace, directory] of config.psr4) { + const existing = psr4.get(namespace); + if (existing !== undefined && existing !== directory) { + hasUnmodeledAutoload = true; + continue; + } + if (existing === undefined) psr4.set(namespace, directory); + } + for (const namespace of config.authoritativePsr4 ?? config.psr4.keys()) { + authoritativePsr4.add(namespace); + } + } + return { psr4, authoritativePsr4, hasUnmodeledAutoload }; +} + /** C# project config parsed from .csproj files */ export interface CSharpProjectConfig { /** Root namespace from or assembly name (default: project directory name) */ @@ -161,22 +253,13 @@ export async function loadComposerConfig(repoRoot: string): Promise(); - for (const [ns, dir] of Object.entries(merged)) { - const nsNorm = (ns as string).replace(/\\+$/, ''); - const dirNorm = (dir as string).replace(/\\/g, '/').replace(/\/+$/, ''); - psr4.set(nsNorm, dirNorm); - } + const config = parseComposerConfig(JSON.parse(raw)); + if (config === null) return null; if (isDev) { - logger.info(`📦 Loaded ${psr4.size} PSR-4 mappings from composer.json`); + logger.info(`📦 Loaded ${config.psr4.size} PSR-4 mappings from composer.json`); } - return { psr4 }; + return config; } catch { return null; } @@ -276,33 +359,32 @@ export async function scanCSharpProject(repoRoot: string): Promise readCsprojConfig(path.join(dir, name), name, repoRoot, dir)), - ); - for (const r of settled) { - const config = r.status === 'fulfilled' ? r.value : null; - if (config) { - configs.push(config); - rootNamespaces.add(config.rootNamespace); - } + // `mapConcurrent` runs the same bounded waves and degrades per item + // (a rejection becomes `undefined`), so entry order is still preserved. + const csprojResults = await mapConcurrent( + csprojNames, + (name) => readCsprojConfig(path.join(dir, name), name, repoRoot, dir), + { concurrency: CSHARP_SCAN_READ_CONCURRENCY }, + ); + for (const config of csprojResults) { + if (config) { + configs.push(config); + rootNamespaces.add(config.rootNamespace); } } - for (let i = 0; i < csNames.length; i += CSHARP_SCAN_READ_CONCURRENCY) { - const batch = csNames.slice(i, i + CSHARP_SCAN_READ_CONCURRENCY); - const settled = await Promise.allSettled( - batch.map((name) => - collectDeclaredNamespaces(path.join(dir, name), declaredNamespaces, rootNamespaces), - ), - ); - // A `.cs` that was unreadable (or whose read/scan unexpectedly rejected) - // leaves its namespaces uncollected → mark truncated to fail the #1881 - // gate OPEN rather than wrongly suppress an import. The scan streams each - // file, so file size no longer trips truncation. - for (const r of settled) { - if (r.status !== 'fulfilled' || r.value === 'truncated') truncated = true; - } + const csResults = await mapConcurrent( + csNames, + (name) => collectDeclaredNamespaces(path.join(dir, name), declaredNamespaces, rootNamespaces), + { concurrency: CSHARP_SCAN_READ_CONCURRENCY }, + ); + // A `.cs` that was unreadable (or whose read/scan unexpectedly rejected) + // leaves its namespaces uncollected → mark truncated to fail the #1881 + // gate OPEN rather than wrongly suppress an import. The scan streams each + // file, so file size no longer trips truncation. A rejected read arrives + // here as `undefined`, which is `!== 'ok'` just like the old + // `r.status !== 'fulfilled'` arm. + for (const r of csResults) { + if (r !== 'ok') truncated = true; } } @@ -470,6 +552,27 @@ export async function loadSwiftPackageConfig(repoRoot: string): Promise { const csharpScan = await scanCSharpProject(repoRoot); diff --git a/gitnexus/src/core/ingestion/language-provider.ts b/gitnexus/src/core/ingestion/language-provider.ts index 903027f70..d5d8048ef 100644 --- a/gitnexus/src/core/ingestion/language-provider.ts +++ b/gitnexus/src/core/ingestion/language-provider.ts @@ -36,16 +36,90 @@ import type { VariableExtractor } from './variable-types.js'; import type { ImportResolverFn } from './import-resolvers/types.js'; import type { SyntaxNode } from './utils/ast-helpers.js'; import type { CfgVisitor } from './cfg/types.js'; -import type { NodeLabel } from 'gitnexus-shared'; +import type { GraphNode, NodeLabel } from 'gitnexus-shared'; import type { ExtractedRoute } from './route-extractors/laravel.js'; import type { SharedSpringType } from './route-extractors/spring-shared.js'; +import type { + ModuleConstants, + Operand, + RepoConstants, +} from './route-extractors/constant-resolver.js'; import type Parser from 'tree-sitter'; import type { ExtractedDecoratorRoute } from './workers/parse-worker.js'; +import type { SpringNonHttpHandlerFact } from './frameworks/spring/non-http-handlers.js'; +import type { SpringMessageProducerFact } from './frameworks/spring/message-producers.js'; + +/** One file's captured Spring async messaging facts, in both directions. */ +export interface SpringMessagingFacts { + /** Callables carrying a listener annotation — the inbound side. */ + readonly handlers: readonly SpringNonHttpHandlerFact[]; + /** Messaging-template publishes — the outbound side. */ + readonly producers: readonly SpringMessageProducerFact[]; +} // ── Shared type aliases ──────────────────────────────────────────────────── /** Tree-sitter query captures: capture name → AST node (or undefined if not captured). */ export type CaptureMap = Record; +export interface DefinitionPropertiesContext { + readonly nodeLabel: NodeLabel; + readonly nodeName: string; + readonly filePath: string; + readonly definitionNode: SyntaxNode; + readonly parsedImports: readonly ParsedImport[]; + readonly isExported: boolean; +} + +export type DefinitionPropertiesExtractor = ( + context: DefinitionPropertiesContext, +) => Readonly> | undefined; + +export interface RuntimeCallableIdentity { + readonly name: string; + readonly descriptorParameterTypes: readonly string[] | undefined; +} + +/** + * Optional language-owned bridge from runtime/compiler symbol identities to + * source graph symbols. Framework importers use this instead of naming + * languages or reproducing compiler conventions in shared ingestion code. + */ +export interface RuntimeSymbolStrategy { + /** Runtime owner names that may contain this callable/property. */ + readonly callableOwnerAliases?: ( + node: GraphNode, + owner: GraphNode | undefined, + ) => readonly string[]; + /** Whether a runtime callable identity can conservatively identify a node. */ + readonly matchesCallable: (node: GraphNode, runtime: RuntimeCallableIdentity) => boolean; +} + +/** Run optional provider enrichment without allowing one hook failure to drop + * the rest of the worker's language batch. */ +export function runDefinitionPropertiesExtractor( + extractor: DefinitionPropertiesExtractor, + context: DefinitionPropertiesContext, + onError: (error: unknown) => void, +): Readonly> | undefined { + try { + return extractor(context); + } catch (error) { + onError(error); + return undefined; + } +} + +/** Provider metadata is additive; graph identity and source-location fields + * supplied by the worker remain authoritative. */ +export function mergeCanonicalDefinitionProperties< + TCanonical extends Readonly>, +>( + providerProperties: Readonly>, + canonicalProperties: TCanonical, +): Record & TCanonical { + return { ...providerProperties, ...canonicalProperties } as Record & TCanonical; +} + // ── Strategy tag types ───────────────────────────────────────────────────── // NOTE: `MroStrategy` is defined in `gitnexus-shared` and re-exported above // so `core/ingestion/model/resolve.ts` can consume it without importing from @@ -64,6 +138,25 @@ export interface AstFrameworkPatternConfig { * Required fields must be explicitly set; optional fields have defaults * applied by defineLanguage(). */ +/** + * Should the parse worker run {@link LanguageProviderConfig.extractModuleConstants} + * on this file? + * + * Exported so the DECISION is testable without booting a worker. It encodes the + * one rule that is easy to get backwards: a provider that declares no + * `moduleConstantHeuristic` harvests unconditionally. Writing the gate as + * `provider.moduleConstantHeuristic?.(content)` reads `undefined` as "skip" and + * silently disables the hook for every provider without a heuristic — which is + * exactly how Python's already-shipped harvest was turned off (#2391/#2980). + */ +export function shouldHarvestModuleConstants( + provider: Pick, + content: string, +): boolean { + if (!provider.extractModuleConstants) return false; + return !provider.moduleConstantHeuristic || provider.moduleConstantHeuristic(content); +} + interface LanguageProviderConfig { // ── Identity ────────────────────────────────────────────────────── readonly id: SupportedLanguages; @@ -130,6 +223,13 @@ interface LanguageProviderConfig { */ readonly preprocessSource?: (sourceText: string, filePath: string) => string; + /** + * Runtime/compiler identity reconciliation for framework metadata. The + * central importer owns ambiguity handling; providers only supply aliases + * and language-specific callable compatibility. + */ + readonly runtimeSymbolStrategy?: RuntimeSymbolStrategy; + // ── Core (required) ─────────────────────────────────────────────── /** Type extraction: declarations, initializers, for-loop bindings */ readonly typeConfig: LanguageTypeConfig; @@ -214,6 +314,20 @@ interface LanguageProviderConfig { * Default: undefined (standard label assignment). */ readonly labelOverride?: (functionNode: SyntaxNode, defaultLabel: NodeLabel) => NodeLabel | null; + /** + * Suppress a definition query match after its default label is known. + * Languages use this for syntax that represents an implicit declaration + * unless an explicit declaration with the same semantics is present. + * + * `defaultLabel` is supplied so an implementation can scope itself to one + * kind of definition; implementations whose capture map alone decides the + * question may ignore it. + */ + readonly shouldSkipDefinitionCapture?: ( + captureMap: CaptureMap, + defaultLabel: NodeLabel, + ) => boolean; + // ── MRO ─────────────────────────────────────────────────────────── /** MRO strategy for multiple inheritance resolution. * Default: 'first-wins'. */ @@ -238,6 +352,10 @@ interface LanguageProviderConfig { * constant, and static declarations. Produces VariableInfo with type, visibility, * isConst, isStatic, isMutable metadata. Default: undefined (no variable extraction). */ readonly variableExtractor?: VariableExtractor; + /** Add language-owned, structured properties to a definition node. Values + * cross the worker boundary and must therefore be structured-clone-safe. + * Shared ingestion code treats these properties as opaque. */ + readonly definitionPropertiesExtractor?: DefinitionPropertiesExtractor; /** Class/type extractor for deriving canonical qualified names for class-like symbols. * Uses the same provider-driven strategy pattern as method/field extraction so * namespace/package/module rules stay language-specific. */ @@ -305,6 +423,28 @@ interface LanguageProviderConfig { lineOffset: number, ) => ExtractedDecoratorRoute[]; + /** + * Name of the function a route decorator captured by the worker's generic + * `@decorator` query applies to, given the decorator's own AST node. + * + * The worker knows a decorator is a route decorator but not how this + * language's grammar attaches it to a definition, so it hands the node over + * unchanged and takes whatever the language returns. Only languages that + * declare route handlers through the generic decorator captures need this; + * languages with a dedicated {@link extractDecoratorRoutes} extractor + * (JS/TS via `nest.ts`, Java via `spring.ts`) already set + * `ExtractedDecoratorRoute.handlerName` there and should leave this undefined. + * + * Implementations must read their own decorated-definition shape directly and + * return undefined for anything else — never climb ancestors to find a name, + * since a decorator that is not attached to a function has no handler and a + * borrowed enclosing name resolves `handlerSymbolId` to the wrong symbol. The + * routes phase treats undefined as "fall back to the file-level edge". + * + * Default: undefined (no handler name from generic decorator captures). + */ + readonly decoratorRouteHandlerName?: (decoratorNode: SyntaxNode) => string | undefined; + /** * Collect a project-wide, language-agnostic view of route-defining * class/interface declarations (`SharedSpringType`) from a parsed file. @@ -322,6 +462,150 @@ interface LanguageProviderConfig { filePath: string, ) => SharedSpringType[]; + /** + * Optional post-capture emission of synthetic structure members (nodes, + * symbols, ownership edges) that have no AST method node — e.g. Lombok + * accessors. Called once per file after the capture loop, at the same + * post-capture site as {@link extractDecoratorRoutes}. + * + * `classOwnersByNodeId` maps in-memory tree-sitter node ids of type + * declarations materialized in THIS file's capture loop to their graph + * node ids. Keys are never persisted; they exist only for the duration + * of the worker pass. + * + * Default: undefined (no synthetic structure members). + */ + readonly synthesizeStructureMembers?: ( + tree: Parser.Tree, + filePath: string, + classOwnersByNodeId: ReadonlyMap, + ) => { + nodes: ReadonlyArray<{ + id: string; + label: string; + properties: Record; + }>; + symbols: ReadonlyArray<{ + filePath: string; + name: string; + nodeId: string; + type: string; + ownerId?: string; + parameterCount?: number; + requiredParameterCount?: number; + parameterTypes?: string[]; + returnType?: string; + visibility?: string; + isStatic?: boolean; + isAbstract?: boolean; + isFinal?: boolean; + }>; + relationships: ReadonlyArray<{ + id: string; + sourceId: string; + targetId: string; + type: string; + confidence: number; + reason: string; + }>; + }; + + /** + * Harvest this file's module-level string constants (#2391 core, #2980 Java + * parity) into the language-agnostic {@link ModuleConstants} shape, so the + * parse phase can resolve non-literal decorator route paths cross-file. + * + * The worker calls this when BOTH hold: + * - the provider declares no `moduleConstantHeuristic`, or the one it + * declares matched — syntax-driven, e.g. a `static final String` field or + * a constants-bearing import; NEVER a class-name pattern like + * `*Constants`, which silently drops route constants living in classes + * named e.g. `ApiPaths`/`Routes`, and + * - the extraction yields something resolvable (a literal, an expression, or + * an import binding), keeping the aggregate bounded on large repos. + * + * Default: undefined (no constant harvest; non-literal route paths of this + * language floor to skip). + */ + readonly extractModuleConstants?: (tree: Parser.Tree) => ModuleConstants; + + /** + * Cheap content heuristic deciding whether the worker should run + * {@link extractModuleConstants} on a file. Guards the harvest cost on huge + * repos: files that cannot contribute (no constant-bearing syntax) are not + * walked. Must be syntax-driven (field/import shape), not identifier + * pattern-matching on class names. + * + * Default: undefined — harvest EVERY file of this language. A gate is opt-in + * because getting it wrong silently drops routes that already resolve, and a + * missed gate only costs time. Declare one only where the cost bites (Java's + * Maven monorepos) and only after checking it against every shape + * {@link extractModuleConstants} accepts. + */ + readonly moduleConstantHeuristic?: (content: string) => boolean; + + /** + * Prepare this language's harvested constants once the complete repo map is + * available and before route operands are folded. The parse phase passes only + * entries owned by this provider, so implementations can build one reusable + * language-specific index and may materialize deferred bindings in place. + * + * Default: undefined (the harvested constants are already fold-ready). + */ + readonly prepareRouteConstants?: (repo: RepoConstants) => void; + + /** + * Spring async messaging facts captured for one file — the listener + * annotations that subscribe to a broker destination and the template calls + * that publish to one. + * + * Both families are collected during capture and restored on the main thread + * by {@link LanguageProviderConfig.applyCaptureSideChannel}, so they are only + * readable AFTER scope resolution has run. The `springDestinations` phase is + * the caller; routing through a provider hook is what keeps that phase from + * naming a language to reach a per-language fact store. + * + * Default: undefined — this language captures no Spring messaging facts, and + * the phase contributes nothing for its files. + */ + readonly getSpringMessagingFacts?: (filePath: string) => SpringMessagingFacts; + + /** + * Whether this language INTERPOLATES its string literals — Kotlin's + * `"orders-$env"` and `"orders-${env}"` are string templates evaluated at + * runtime, while Java's are ordinary characters. + * + * A capability rather than a language name, because shared ingestion code may + * not branch on a language (see AGENTS.md) and because the capability is what + * the consumer actually needs. Spring destination resolution is the caller: + * in an interpolating language an unescaped `$` in a destination literal is a + * runtime value and must be refused, and `"${app.topic}"` is a TEMPLATE, not + * a Spring property placeholder — the placeholder has to be written + * `"\${app.topic}"` there. Reading either as an address gives two unrelated + * services one shared destination node. + * + * Default: false — literals are literal, `$` is a character. + */ + readonly interpolatesStringLiterals?: boolean; + + /** + * Fold one file's non-literal route-path operand list + * (`routePathExpr`/`routePathOperands` of an `ExtractedDecoratorRoute`) + * against the repo-wide, file-path-keyed constant map, or null when it cannot + * be fully folded (skip floor — never a phantom path). Languages whose + * qualified refs resolve through class imports (`Outer.CONST`, + * `com.example.ApiPaths.USERS`) need this hook because the shared fold has no + * notion of qualified names; Python's bare-name refs use the shared default. + * + * Default: undefined (the parse phase falls back to the shared + * language-agnostic operand fold). + */ + readonly foldRoutePathOperands?: ( + filePath: string, + operands: readonly Operand[], + repo: RepoConstants, + ) => string | null; + // ── Noise filtering ──────────────────────────────────────────────── /** Built-in/stdlib names that should be filtered from the call graph for this language. * Default: undefined (no language-specific filtering). */ @@ -443,6 +727,94 @@ interface LanguageProviderConfig { */ readonly interpretImport?: (captures: CaptureMatch) => ParsedImport | null; + /** + * Do this language's imports EXECUTE at the point in the program where they + * are written? + * + * The scope extractor marks an import `runsOnlyWhenCalled` when the statement + * sits inside a `Function` scope (Pass 3): where imports are executed + * statements, one written in a function body runs only when that function is + * called — Python's `def f(): from x import Y`, Ruby's + * `def f; require 'x'; end`, a CommonJS `require()` in a body (which + * `javascript/captures.ts` does capture, via its own AST walk). That rule is + * about EXECUTION. It says nothing true about a language whose "import" is + * not an executed statement at all, and in such a language moving one into a + * function body defers exactly nothing. + * + * "Where written" is about execution time, not textual placement: a + * `#include` is spliced precisely where it is written and still answers + * `false`, because splicing is not running. + * + * **The failure directions are not symmetric, which is why the default is + * what it is.** Answering `false` for a language that really does execute + * its imports un-defers a deliberately lazy one, and `check --cycles` + * reports a cycle its author broke on purpose — wrong, but visible on screen + * and arguable by whoever reads it. Answering `true` for a language that + * does not SUPPRESSES a cycle that is entirely real: nobody sees it, so + * nobody can argue with it. Getting this wrong in the `true` direction hides + * a true cycle, and that is the failure that matters. + * + * Absent — the default — reads as `true`, so every provider that does not + * name this keeps today's behaviour exactly. The flag never ADDS deferral; + * declaring `false` only WITHHOLDS it. + * + * Declared `false` by, and only by: + * + * - **C and C++** — `#include` is a preprocessor directive. The header's + * text is spliced in before a line of the program runs, wherever the + * directive sits, and C permits one inside a function body. C++'s only + * other Pass-3 import form, `using ns::name` / `using namespace ns`, is + * compile-time name lookup, is legal in a function body too, and defers + * no more than an `#include` does. + * - **Rust** — `use` is a compile-time path alias, not a statement that + * runs. `fn f() { use crate::m::X; }` is legal, and putting the `use` + * there changes only where the name is VISIBLE, never when anything + * happens; Rust has no module-initialization order in the JS/Python + * sense and permits intra-crate module cycles outright. It is the + * structural twin of C++'s `using ns::name`. `rust/query.ts` captures + * `(use_declaration)` and nothing else, so this covers the whole + * surface. (The claim here is the narrow one: POSITION does not defer a + * Rust import. Whether a Rust `use` can create an initialization + * dependency *at all* is a larger and separate question, and this flag + * deliberately does not answer it.) + * - **COBOL** — `COPY` is a pure textual splice performed by the copybook + * preprocessor, the `#include` case exactly. Latent today: the COBOL + * `@scope.function` capture covers a single line (`cobol/captures.ts` + * ranges sections and paragraphs `line → line`), so a `COPY` on any + * later line never resolves inside one and Pass 3 has nothing to mark. + * Declared anyway, so that giving those anchors their true multi-line + * ranges cannot silently start suppressing real copybook cycles. + * + * Per-provider rather than per-import, and that is sufficient — the question + * this answers is narrower than "do this language's imports execute". It is + * only ever asked of an import that resolved INSIDE A FUNCTION SCOPE, so the + * real domain is: *can a function-local import in this language be + * non-executing?* No supported language has two forms that are both + * function-local and disagree. C++ has two forms, `#include` and + * `using ns::name`; both can appear in a body and both are compile-time. + * + * PHP is the case that looks like a counterexample and is not. It does mix — + * `use Foo\Bar;` aliases at compile time while `require` executes — but + * `use` cannot appear in a function body at all (`php/query.ts` records this + * twice: "`namespace_use_declaration` is an import only at top level / inside + * namespace scope"), so it never reaches this flag. Absent is therefore + * PERMANENTLY correct for PHP, including the day `require` is captured: a + * `require` in a body will correctly defer, and a `use` still cannot get + * here. Do not read PHP as a reason to build a per-`ParsedImport` + * classification hook — it would cost a provider call per import on the + * extractor's hot path, and `ParsedImport.kind` does not discriminate the + * thing being asked anyway. + * + * A capability on the provider rather than a language check in + * `scope-extractor.ts`: shared `core/ingestion/` pipeline code must not name + * languages (AGENTS.md), and "imports here are not executed statements" is a + * property of the language, not of the walk. + * + * Default: undefined, read as `true` (imports execute where they are + * written; position defers them). + */ + readonly importsExecuteWhereWritten?: boolean; + /** * What is the implicit receiver on a Function scope? For instance methods * this is `self`/`this`; for standalone functions it is `null`. Consulted @@ -527,7 +899,7 @@ interface LanguageProviderConfig { readonly resolveImportTarget?: ( parsedImport: ParsedImport, workspaceIndex: WorkspaceIndex, - ) => string | null; + ) => string | readonly string[] | null; /** * Enumerate the exported names of a file — used by the finalize algorithm @@ -634,6 +1006,34 @@ export interface LanguageProvider extends Omit boolean; } +/** + * Run each provider's repo-constant preparation hook once over only the files + * that provider owns. Values are shared with `repo`, so in-place preparation + * is visible to the subsequent fold without copying the complete map. + */ +export function prepareRouteConstantsByProvider( + repo: RepoConstants, + providerForFile: (filePath: string) => Pick | null, +): void { + const slices = new Map< + Pick, + Map + >(); + for (const [filePath, constants] of repo) { + const provider = providerForFile(filePath); + if (!provider?.prepareRouteConstants) continue; + let slice = slices.get(provider); + if (!slice) { + slice = new Map(); + slices.set(provider, slice); + } + slice.set(filePath, constants); + } + for (const [provider, slice] of slices) { + provider.prepareRouteConstants?.(slice); + } +} + const DEFAULTS: Pick = { mroStrategy: 'first-wins', }; diff --git a/gitnexus/src/core/ingestion/languages/c-cpp.ts b/gitnexus/src/core/ingestion/languages/c-cpp.ts index 5378c2260..88961bcf9 100644 --- a/gitnexus/src/core/ingestion/languages/c-cpp.ts +++ b/gitnexus/src/core/ingestion/languages/c-cpp.ts @@ -420,6 +420,13 @@ export const cProvider = defineLanguage({ collectCaptureSideChannel: (filePath) => assertCloneable(collectCStaticLinkageSideChannel(filePath)), interpretImport: interpretCImport, + // `#include` is a preprocessor directive, not a statement that runs. The + // header text is spliced in before the program starts, wherever the directive + // sits — and C allows it inside a function body. Without this the central + // Pass-3 position rule would mark such an include `runsOnlyWhenCalled` and + // `check --cycles` would silently drop an include cycle that is entirely + // real. See `LanguageProvider.importsExecuteWhereWritten`. + importsExecuteWhereWritten: false, interpretTypeBinding: interpretCTypeBinding, bindingScopeFor: cBindingScopeFor, importOwningScope: cImportOwningScope, @@ -500,6 +507,12 @@ export const cppProvider = defineLanguage({ // re-parse (#1983). See `cpp/capture-side-channel.ts`. collectCaptureSideChannel: (filePath) => assertCloneable(collectCppCaptureSideChannel(filePath)), interpretImport: interpretCppImport, + // Same as C — `cpp/query.ts` emits `@import.statement` for `preproc_include` + // too. It holds for C++'s whole import surface: the only other form Pass 3 + // sees is `@import.using-decl` (`using ns::name` / `using namespace ns`), + // which is a compile-time name-lookup declaration and executes no more than + // an `#include` does. See the note on `cProvider`. + importsExecuteWhereWritten: false, interpretTypeBinding: interpretCppTypeBinding, bindingScopeFor: cppBindingScopeFor, importOwningScope: cppImportOwningScope, diff --git a/gitnexus/src/core/ingestion/languages/c/import-target.ts b/gitnexus/src/core/ingestion/languages/c/import-target.ts index 495846030..5590bb2e6 100644 --- a/gitnexus/src/core/ingestion/languages/c/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/c/import-target.ts @@ -1,4 +1,5 @@ import { dirname, join } from 'path'; +import { perFileSet } from '../../import-resolvers/per-file-set.js'; /** * A workspace file path pre-decomposed for the suffix-match fallback: @@ -28,26 +29,20 @@ interface CSuffixCandidate { * `WeakMap`-keyed so it is reclaimed with the pass (no cross-pass staleness). * Shared by C and C++ (`resolveCppImportTarget` delegates here). */ -const suffixIndexByPaths = new WeakMap, Map>(); - -function suffixIndex(allFilePaths: ReadonlySet): Map { - let index = suffixIndexByPaths.get(allFilePaths); - if (index === undefined) { - index = new Map(); - for (const original of allFilePaths) { - const normalized = original.replace(/\\/g, '/'); - const basename = normalized.slice(normalized.lastIndexOf('/') + 1); - let bucket = index.get(basename); - if (bucket === undefined) { - bucket = []; - index.set(basename, bucket); - } - bucket.push({ original, normalized, depth: normalized.split('/').length }); +const suffixIndex = perFileSet((allFilePaths: ReadonlySet) => { + const index = new Map(); + for (const original of allFilePaths) { + const normalized = original.replace(/\\/g, '/'); + const basename = normalized.slice(normalized.lastIndexOf('/') + 1); + let bucket = index.get(basename); + if (bucket === undefined) { + bucket = []; + index.set(basename, bucket); } - suffixIndexByPaths.set(allFilePaths, index); + bucket.push({ original, normalized, depth: normalized.split('/').length }); } return index; -} +}); /** * Resolve a C #include path to a file in the workspace. diff --git a/gitnexus/src/core/ingestion/languages/c/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/c/scope-resolver.ts index af31ec572..c3f9fb36f 100644 --- a/gitnexus/src/core/ingestion/languages/c/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/c/scope-resolver.ts @@ -8,6 +8,7 @@ import { cArityCompatibility, cMergeBindings, resolveCImportTarget } from './ind import { scanHeaderFiles } from './header-scan.js'; import { expandCWildcardNames, isStaticName, clearStaticNames } from './static-linkage.js'; import { applyCStaticLinkageSideChannel } from './capture-side-channel.js'; +import { perFileSet } from '../../import-resolvers/per-file-set.js'; /** * Per-pass memo of the augmented `#include`-resolution file set @@ -19,31 +20,26 @@ import { applyCStaticLinkageSideChannel } from './capture-side-channel.js'; * handing it a new set identity each time. Both `allFilePaths` (built once in * scope-resolution `run.ts`) and the header set (`loadResolutionConfig` * result) are stable per pass, so the union is built once and reused. - * `WeakMap`-keyed → reclaimed with the pass (no cross-pass staleness). + * Reclaimed with the pass (no cross-pass staleness). + * + * Two inputs, so two levels of `perFileSet` composed rather than a second + * primitive: the outer memo's value is the inner memo, and a function is an + * object, which is all `T extends object` asks for. + * + * The MEMO stays private to this file even though the C++ resolver's twin is + * byte-identical. The augmented set's IDENTITY is load-bearing downstream — + * C++ delegates to `resolveCImportTarget`, whose `suffixIndex` memo is keyed on + * exactly this set — so one memo shared across the two languages would hand + * each the other's index. Same builder-shared/memo-separate rule as + * `import-resolvers/pass-cache.ts`. */ -const augmentedPathsByPass = new WeakMap< - ReadonlySet, - WeakMap, ReadonlySet> ->(); - -function augmentedFilePaths( - allFilePaths: ReadonlySet, - headerPaths: ReadonlySet, -): ReadonlySet { - let byHeaders = augmentedPathsByPass.get(allFilePaths); - if (byHeaders === undefined) { - byHeaders = new WeakMap(); - augmentedPathsByPass.set(allFilePaths, byHeaders); - } - let augmented = byHeaders.get(headerPaths); - if (augmented === undefined) { +const augmentedFilePathsFor = perFileSet((allFilePaths: ReadonlySet) => + perFileSet((headerPaths: ReadonlySet): ReadonlySet => { const set = new Set(allFilePaths); for (const h of headerPaths) set.add(h); - augmented = set; - byHeaders.set(headerPaths, augmented); - } - return augmented; -} + return set; + }), +); /** * C `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed by @@ -94,7 +90,7 @@ export const cScopeResolver: ScopeResolver = { return resolveCImportTarget( targetRaw, fromFile, - augmentedFilePaths(allFilePaths, headerPaths), + augmentedFilePathsFor(allFilePaths)(headerPaths), ); } return resolveCImportTarget(targetRaw, fromFile, allFilePaths); diff --git a/gitnexus/src/core/ingestion/languages/c/static-linkage.ts b/gitnexus/src/core/ingestion/languages/c/static-linkage.ts index 2cc195205..a81398354 100644 --- a/gitnexus/src/core/ingestion/languages/c/static-linkage.ts +++ b/gitnexus/src/core/ingestion/languages/c/static-linkage.ts @@ -1,4 +1,5 @@ import type { ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared'; +import { perFileSet } from '../../import-resolvers/per-file-set.js'; /** * Per-file set of function names declared with `static` storage class. @@ -59,27 +60,23 @@ export function clearStaticNames(): void { * thousands of resolved includes) that is ~10^10+ comparisons on a single * thread — the dominant term in the scope-resolution finalize grind. * - * Building the lookup once collapses it to O(R_include + F). `WeakMap`-keyed - * on the array so the index is reclaimed with the pass — no cross-pass + * Building the lookup once collapses it to O(R_include + F). `perFileSet` keys + * on the array identity so the index is reclaimed with the pass — no cross-pass * staleness (mirrors the {@link clearStaticNames} discipline for server-mode * / multi-repo reuse), and a fresh array transparently rebuilds. */ -const moduleScopeIndexByPass = new WeakMap>(); - -function moduleScopeIndex(parsedFiles: readonly ParsedFile[]): Map { - let index = moduleScopeIndexByPass.get(parsedFiles); - if (index === undefined) { - index = new Map(); +const moduleScopeIndex = perFileSet( + (parsedFiles: readonly ParsedFile[]): Map => { + const index = new Map(); // First-wins to preserve `Array.find` semantics (returns the first match). // `moduleScope` is unique per file in practice, so collisions are absent; // the guard only formalises identical behaviour to the prior `.find`. for (const p of parsedFiles) { if (!index.has(p.moduleScope)) index.set(p.moduleScope, p); } - moduleScopeIndexByPass.set(parsedFiles, index); - } - return index; -} + return index; + }, +); /** * Return the names visible through a C wildcard import (`#include`). diff --git a/gitnexus/src/core/ingestion/languages/cobol.ts b/gitnexus/src/core/ingestion/languages/cobol.ts index 44891cef4..4a8ba2e2e 100644 --- a/gitnexus/src/core/ingestion/languages/cobol.ts +++ b/gitnexus/src/core/ingestion/languages/cobol.ts @@ -42,6 +42,19 @@ export const cobolProvider = defineLanguage({ // ── Scope-resolution hooks ─────────────────────────────────────── emitScopeCaptures: emitCobolScopeCaptures, interpretImport: interpretCobolImport, + // `COPY` is a pure textual splice by the copybook preprocessor — the + // `#include` case exactly, and COBOL's only import form. It is spliced before + // anything runs, so a copybook cycle built from `COPY` statements is real and + // must not be tagged `runsOnlyWhenCalled` by the central Pass-3 position rule. + // + // LATENT today, declared anyway. `cobol/captures.ts` ranges every + // `@scope.function` (PROCEDURE DIVISION sections and paragraphs) over a + // SINGLE line — `rangeOf(line, 0, line, endCol)` — so a `COPY` on any later + // line never resolves inside a Function scope and Pass 3 has nothing to mark. + // The flag is here so that giving those anchors their true multi-line ranges + // is a scope-resolution fix and not, silently, a cycle-suppression bug. + // See `LanguageProvider.importsExecuteWhereWritten`. + importsExecuteWhereWritten: false, importOwningScope: cobolImportOwningScope, receiverBinding: cobolReceiverBinding, }); diff --git a/gitnexus/src/core/ingestion/languages/cobol/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/cobol/scope-resolver.ts index 9e528ce16..cfea8f39e 100644 --- a/gitnexus/src/core/ingestion/languages/cobol/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/cobol/scope-resolver.ts @@ -12,12 +12,71 @@ import path from 'node:path'; import type { ParsedFile } from 'gitnexus-shared'; import { SupportedLanguages } from 'gitnexus-shared'; +import { perFileSet } from '../../import-resolvers/per-file-set.js'; import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js'; import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js'; import { cobolProvider } from '../cobol.js'; // Copybook file extensions for COPY name resolution const COPYBOOK_EXTENSIONS = new Set(['.cpy', '.copybook']); +// COBOL source files, searched only after every copybook has missed. +const COBOL_SOURCE_EXTENSIONS = new Set(['.cbl', '.cob', '.cobol']); + +/** + * Uppercased-basename → first file carrying it, one map PER TIER, memoized on + * the `allFilePaths` Set identity (#2908). + * + * `resolveImportTarget` used to run two full workspace scans per `COPY` — one + * for the copybook tier, one for the source tier — each calling `path.extname` + * + `path.basename` + `toUpperCase` on every entry. A `COPY` of a member that + * lives outside the repo (the common case: vendor and system copybooks) missed + * in both, so both scans always ran to completion, making resolution + * O(copies × files). The orchestrator passes the SAME Set to every import in a + * pass (`pipeline/run.ts` builds it once), so a `WeakMap` keyed on that Set + * turns the scans into one build per run. + * + * Two tiers rather than one map is the tie-break, not a stylistic choice: a + * `.cpy`/`.copybook` hit beats a `.cbl`/`.cob`/`.cobol` hit even when the source + * file comes FIRST in Set-iteration order, which is exactly what collapsing the + * tiers into a single first-wins map would silently discard. Within a tier the + * first file in Set-iteration order wins, mirroring the `return` on first match + * in the scans this replaces. + * + * The per-file key is derived with the same `path.extname(fp).toLowerCase()` → + * `path.basename(fp, ext)` → `toUpperCase()` sequence the scans used, including + * its quirk: `path.basename` strips the suffix only on an exact, case-sensitive + * match, so `Foo.CPY` indexes under `FOO.CPY` rather than `FOO`. Node's `path` + * stays in the loop for the same reason — on POSIX it does not treat `\` as a + * separator, and hand-rolled slicing on `/` would start resolving backslash + * paths the scans never resolved. + */ +interface CobolCopyIndex { + /** `.cpy` / `.copybook` files — tier 1. */ + readonly copybooks: ReadonlyMap; + /** `.cbl` / `.cob` / `.cobol` files — tier 2. */ + readonly sources: ReadonlyMap; +} + +const getCobolCopyIndex = perFileSet((allFilePaths: ReadonlySet): CobolCopyIndex => { + const copybooks = new Map(); + const sources = new Map(); + // One pass builds both tiers: the two scans walked the same files and + // classified each by the same extension test. + for (const fp of allFilePaths) { + const ext = path.extname(fp).toLowerCase(); + const tier = COPYBOOK_EXTENSIONS.has(ext) + ? copybooks + : COBOL_SOURCE_EXTENSIONS.has(ext) + ? sources + : undefined; + if (tier === undefined) continue; + const basename = path.basename(fp, ext).toUpperCase(); + // First in Set-iteration order wins, as the scans' first-match `return` did. + if (!tier.has(basename)) tier.set(basename, fp); + } + + return { copybooks, sources }; +}); const cobolScopeResolver: ScopeResolver = { language: SupportedLanguages.Cobol, @@ -27,22 +86,9 @@ const cobolScopeResolver: ScopeResolver = { // ── Resolve COPY bookname to file path ───────────────────────────── resolveImportTarget: (targetRaw, _fromFile, allFilePaths) => { const upper = targetRaw.toUpperCase(); - // Check copybook files first - for (const fp of allFilePaths) { - const ext = path.extname(fp).toLowerCase(); - if (!COPYBOOK_EXTENSIONS.has(ext)) continue; - const basename = path.basename(fp, ext).toUpperCase(); - if (basename === upper) return fp; - } - // Also search COBOL source files (.cbl, .cob, .cobol) - const COBOL_SOURCE_EXTS = new Set(['.cbl', '.cob', '.cobol']); - for (const fp of allFilePaths) { - const ext = path.extname(fp).toLowerCase(); - if (!COBOL_SOURCE_EXTS.has(ext)) continue; - const basename = path.basename(fp, ext).toUpperCase(); - if (basename === upper) return fp; - } - return null; + const index = getCobolCopyIndex(allFilePaths); + // Copybooks first, then COBOL sources — the tier order IS the tie-break. + return index.copybooks.get(upper) ?? index.sources.get(upper) ?? null; }, // COBOL has no binding-merge rules beyond the default (local-first-then-imports). diff --git a/gitnexus/src/core/ingestion/languages/cpp/file-local-linkage.ts b/gitnexus/src/core/ingestion/languages/cpp/file-local-linkage.ts index c6b383bf0..e578b20d4 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/file-local-linkage.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/file-local-linkage.ts @@ -1,4 +1,5 @@ import type { ParsedFile, Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared'; +import { perFileSet } from '../../import-resolvers/per-file-set.js'; import { isCppInlineNamespaceScope } from './inline-namespaces.js'; /** @@ -283,24 +284,20 @@ export function isCppDefGloballyVisible(filePath: string, nodeId: string): boole * `parsedFiles` reference; the old `parsedFiles.find(...)` was therefore O(F) * per edge → O(R·F) overall (at kernel scale the ~25–30k `.h` headers are * classified C++, so this fires hard — the C twin in `c/static-linkage.ts`). - * Building the lookup once collapses it to O(R+F). `WeakMap`-keyed so it is - * reclaimed with the pass (no cross-pass staleness; mirrors - * {@link clearFileLocalNames}). + * Building the lookup once collapses it to O(R+F). `perFileSet` keys on the + * array identity so it is reclaimed with the pass (no cross-pass staleness; + * mirrors {@link clearFileLocalNames}). */ -const moduleScopeIndexByPass = new WeakMap>(); - -function moduleScopeIndex(parsedFiles: readonly ParsedFile[]): Map { - let index = moduleScopeIndexByPass.get(parsedFiles); - if (index === undefined) { - index = new Map(); +const moduleScopeIndex = perFileSet( + (parsedFiles: readonly ParsedFile[]): Map => { + const index = new Map(); // First-wins to preserve `Array.find` semantics (returns the first match). for (const p of parsedFiles) { if (!index.has(p.moduleScope)) index.set(p.moduleScope, p); } - moduleScopeIndexByPass.set(parsedFiles, index); - } - return index; -} + return index; + }, +); export function expandCppWildcardNames( targetModuleScope: ScopeId, diff --git a/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts index 13f21183e..9421975ea 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts @@ -48,6 +48,7 @@ import { resolveCppReceiverMember, } from './member-lookup.js'; import { stripCppSpecifiers } from './interpret.js'; +import { perFileSet } from '../../import-resolvers/per-file-set.js'; /** A pointee worth binding: a bare identifier, not `T**`, `T[]`, `A::B` or a * template spelling. Hoisted — a literal here would mint a fresh RegExp on @@ -61,32 +62,25 @@ const CPP_SIMPLE_POINTEE_RE = /^[A-Za-z_]\w*$/; * a fresh ~F-entry `Set` on every call AND defeated the shared * `resolveCImportTarget` suffix-index memo (in `c/import-target.ts`) by handing * it a new set identity each time. Both inputs are stable per pass, so the - * union is built once and reused. `WeakMap`-keyed → reclaimed with the pass. - * (Twin of the C resolver's `augmentedFilePaths`.) + * union is built once and reused. Reclaimed with the pass. + * + * Two inputs, so two levels of `perFileSet` composed rather than a second + * primitive: the outer memo's value is the inner memo, and a function is an + * object, which is all `T extends object` asks for. + * + * (Twin of the C resolver's `augmentedFilePathsFor`.) The two memos stay + * SEPARATE deliberately. C++ delegates to `resolveCImportTarget`, whose + * `suffixIndex` memo is keyed on the augmented set, so a single memo shared + * with C would hand each language the other's index — same + * builder-shared/memo-separate rule as `import-resolvers/pass-cache.ts`. */ -const augmentedPathsByPass = new WeakMap< - ReadonlySet, - WeakMap, ReadonlySet> ->(); - -function augmentedFilePaths( - allFilePaths: ReadonlySet, - headerPaths: ReadonlySet, -): ReadonlySet { - let byHeaders = augmentedPathsByPass.get(allFilePaths); - if (byHeaders === undefined) { - byHeaders = new WeakMap(); - augmentedPathsByPass.set(allFilePaths, byHeaders); - } - let augmented = byHeaders.get(headerPaths); - if (augmented === undefined) { +const augmentedFilePathsFor = perFileSet((allFilePaths: ReadonlySet) => + perFileSet((headerPaths: ReadonlySet): ReadonlySet => { const set = new Set(allFilePaths); for (const h of headerPaths) set.add(h); - augmented = set; - byHeaders.set(headerPaths, augmented); - } - return augmented; -} + return set; + }), +); /** * C++ `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed by @@ -128,7 +122,7 @@ export const cppScopeResolver: ScopeResolver = { return resolveCppImportTarget( targetRaw, fromFile, - augmentedFilePaths(allFilePaths, headerPaths), + augmentedFilePathsFor(allFilePaths)(headerPaths), ); } return resolveCppImportTarget(targetRaw, fromFile, allFilePaths); diff --git a/gitnexus/src/core/ingestion/languages/csharp/import-target.ts b/gitnexus/src/core/ingestion/languages/csharp/import-target.ts index 3745e16de..68f1e484d 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/import-target.ts @@ -22,7 +22,16 @@ import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared'; import type { CSharpProjectConfig, CSharpNamespaceEvidence } from '../../language-config.js'; import { resolveCSharpImportInternal } from '../../import-resolvers/csharp.js'; -import { buildSuffixIndex, type SuffixIndex } from '../../import-resolvers/utils.js'; +import { + getWorkspaceFileIndex, + type WorkspaceFileIndex, +} from '../../import-resolvers/workspace-file-index.js'; +import { + buildPackageDirIndex, + firstFileDirectlyInPkgDir, + type PackageDirIndex, +} from '../../import-resolvers/package-dir-index.js'; +import { perFileSet } from '../../import-resolvers/per-file-set.js'; import { csharpSuffixFallbackAllowed } from '../../csharp-namespace-gate.js'; export interface CsharpResolveContext { @@ -32,27 +41,16 @@ export interface CsharpResolveContext { readonly namespaces?: CSharpNamespaceEvidence; } -/** Normalized file list + suffix index, built once per workspace `allFilePaths`. */ -interface WorkspaceFileIndex { - readonly normalized: string[]; - readonly all: string[]; - readonly index: SuffixIndex; -} - -// Memoize on Set identity: the orchestrator passes the SAME `allFilePaths` -// Set through every `resolveImportTarget` call in a pass, so this rebuilds -// the normalized list + suffix index once instead of once per import (#1881 #2). -const workspaceFileIndexCache = new WeakMap, WorkspaceFileIndex>(); - -function getWorkspaceFileIndex(allFilePaths: ReadonlySet): WorkspaceFileIndex { - const cached = workspaceFileIndexCache.get(allFilePaths); - if (cached) return cached; - const all = [...allFilePaths]; - const normalized = all.map((f) => f.replace(/\\/g, '/')); - const built: WorkspaceFileIndex = { normalized, all, index: buildSuffixIndex(normalized, all) }; - workspaceFileIndexCache.set(allFilePaths, built); - return built; -} +/** + * Namespace-directory index over the `.cs` files, memoized on the Set's + * identity. Feeds `firstFileDirectlyInPkgDir` (in + * `import-resolvers/package-dir-index.ts`), which the no-csproj path calls once + * for the direct match and then up to once per stripped namespace prefix. + */ +const getCsharpDirIndex = perFileSet( + (allFilePaths: ReadonlySet): PackageDirIndex => + buildPackageDirIndex(allFilePaths, (normalized) => normalized.endsWith('.cs')), +); export function resolveCsharpImportTarget( parsedImport: ParsedImport, @@ -67,12 +65,11 @@ export function resolveCsharpImportTarget( const csharpConfigs = ctx.csharpConfigs ?? []; if (csharpConfigs.length > 0) { - const { normalized, all, index } = getWorkspaceFileIndex(ctx.allFilePaths); + const { index } = getWorkspaceFileIndex(ctx.allFilePaths); const fromCsproj = resolveCSharpImportInternal( targetRaw, [...csharpConfigs], - normalized, - all, + ctx.allFilePaths, index, evidence, ); @@ -101,12 +98,19 @@ export function resolveCsharpImportTarget( } // Exact file / nested-suffix / namespace-dir direct-child match. - const direct = resolveDirectMatch(ctx.allFilePaths, pathLike); + // + // The no-csproj path used to take the raw Set and re-scan it — up to eight + // full workspace passes for a four-segment `using` — past the memoized index + // sitting right there for the csproj branch (#2878). Both legs now read the + // same per-run indexes. + const ws = getWorkspaceFileIndex(ctx.allFilePaths); + const dirs = getCsharpDirIndex(ctx.allFilePaths); + const direct = resolveDirectMatch(ws, dirs, pathLike); if (direct !== null) return direct; // Progressive prefix stripping — mirrors csproj's root-namespace mapping // without the csproj. - return resolveByProgressiveStripping(ctx.allFilePaths, pathLike); + return resolveByProgressiveStripping(ws, dirs, pathLike); } /** @@ -131,40 +135,28 @@ function narrowContext(workspaceIndex: WorkspaceIndex): CsharpResolveContext | n * exact whole-path file > nested suffix file > first `.cs` directly inside * the namespace directory. */ -function resolveDirectMatch(allFilePaths: ReadonlySet, pathLike: string): string | null { +function resolveDirectMatch( + ws: WorkspaceFileIndex, + dirs: PackageDirIndex, + pathLike: string, +): string | null { const exactName = `${pathLike}.cs`; - const nestedSuffix = `/${exactName}`; - let suffixFile: string | null = null; - for (const raw of allFilePaths) { - const f = raw.replace(/\\/g, '/'); - if (!f.endsWith('.cs')) continue; - if (f === exactName) return raw; // exact whole-path match wins - if (suffixFile === null && f.endsWith(nestedSuffix)) suffixFile = raw; - } - if (suffixFile !== null) return suffixFile; - return findDirectChild(allFilePaths, pathLike); -} - -/** - * First `.cs` file that lives directly inside the namespace directory - * `dirSegment` (at repo root or nested under a project prefix), not deeper. - * The legacy resolver emits all of them; the scope-resolver contract is - * single-target so we take one. - */ -function findDirectChild(allFilePaths: ReadonlySet, dirSegment: string): string | null { - const dirPrefix = `${dirSegment}/`; - const nestedDirPrefix = `/${dirPrefix}`; - for (const raw of allFilePaths) { - const f = raw.replace(/\\/g, '/'); - if (!f.endsWith('.cs')) continue; - const atRoot = f.startsWith(dirPrefix); - const atNested = f.includes(nestedDirPrefix); - if (!atRoot && !atNested) continue; - const idx = atRoot ? 0 : f.indexOf(nestedDirPrefix) + 1; - const after = f.slice(idx + dirPrefix.length); - if (after.length > 0 && !after.includes('/')) return raw; - } - return null; + // An exact whole-path match wins even when a `…/` suffix match + // appeared EARLIER in iteration order, so the two lookups stay separate: + // `index.get` conflates them and would return the earlier suffix hit. + const exact = ws.normToRaw.get(exactName); + if (exact !== undefined) return exact; + // No whole-path file exists, so every segment-suffix hit is a `/` + // match and `index.get` yields the first one in iteration order — exactly the + // `suffixFile` the scan kept. Only a `.cs` file can carry a `.cs` suffix key, + // so the old `endsWith('.cs')` filter is implied. + const suffixFile = ws.index.get(exactName); + if (suffixFile !== undefined) return suffixFile; + // First `.cs` file living directly inside the namespace directory `pathLike` + // (at repo root or nested under a project prefix), not deeper. The legacy + // resolver emits all of them; the scope-resolver contract is single-target so + // we take one. + return firstFileDirectlyInPkgDir(dirs, pathLike); } /** @@ -174,26 +166,20 @@ function findDirectChild(allFilePaths: ReadonlySet, dirSegment: string): * prefix (the scope-resolver layer has no csproj to consult). */ function resolveByProgressiveStripping( - allFilePaths: ReadonlySet, + ws: WorkspaceFileIndex, + dirs: PackageDirIndex, pathLike: string, ): string | null { const segments = pathLike.split('/').filter(Boolean); for (let skip = 1; skip < segments.length; skip++) { const tail = segments.slice(skip).join('/'); if (tail === '') continue; - const tailFile = `${tail}.cs`; - const tailSuffix = `/${tailFile}`; - let tailFileMatch: string | null = null; - for (const raw of allFilePaths) { - const f = raw.replace(/\\/g, '/'); - if (!f.endsWith('.cs')) continue; - if (f === tailFile || f.endsWith(tailSuffix)) { - tailFileMatch = raw; - break; - } - } - if (tailFileMatch !== null) return tailFileMatch; - const child = findDirectChild(allFilePaths, tail); + // `f === tailFile || f.endsWith('/' + tailFile)`, first in iteration order — + // no exact-wins rule here, unlike `resolveDirectMatch`, so the conflated + // suffix lookup is the right one. + const tailFileMatch = ws.index.get(`${tail}.cs`); + if (tailFileMatch !== undefined) return tailFileMatch; + const child = firstFileDirectlyInPkgDir(dirs, tail); if (child !== null) return child; } return null; diff --git a/gitnexus/src/core/ingestion/languages/csharp/query.ts b/gitnexus/src/core/ingestion/languages/csharp/query.ts index 615da3c21..18c37ba2b 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/query.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/query.ts @@ -93,8 +93,14 @@ const CSHARP_SCOPE_QUERY = ` name: (identifier) @declaration.name) @declaration.enum ;; Declarations — methods / constructors / properties +;; +;; A generic METHOD's parameters are read for the same reason a generic type's +;; are (#2912 review): \`void Run(IValidator v)\` writes a receiver whose +;; argument is a type VARIABLE, and a pass that cannot tell that from a concrete +;; type prunes every implementor of \`IValidator\` from the call's fan-out. (method_declaration - name: (identifier) @declaration.name) @declaration.method + name: (identifier) @declaration.name + (type_parameter_list)? @declaration.type-parameters) @declaration.method (constructor_declaration name: (identifier) @declaration.name) @declaration.constructor diff --git a/gitnexus/src/core/ingestion/languages/csharp/razor-view-components.ts b/gitnexus/src/core/ingestion/languages/csharp/razor-view-components.ts new file mode 100644 index 000000000..0e7ea55a4 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/csharp/razor-view-components.ts @@ -0,0 +1,955 @@ +/** + * ASP.NET Core ViewComponent convention support. + * + * Same bound as Spring Boot DI in Java/Kotlin: do not resolve into the SDK + * (`Microsoft.AspNetCore.Mvc.ViewComponent`, `IViewComponentHelper`, + * `Component.InvokeAsync` itself). Those types live outside the workspace. + * The only hop worth taking is the framework convention that lands on an + * **in-repo** class — `InvokeAsync("Foo")` → workspace `FooViewComponent`, + * just as a Spring `@Autowired IFoo` fans out to an in-repo `@Service`, + * not to `ApplicationContext`. + * + * Razor templates are not parsed as C# (markup + code would poison + * tree-sitter-c-sharp). A small Razor state machine extracts C# islands and + * markup tag helpers; C# files use a string/comment-aware lexer so attributes + * and literals are not mistaken for helper calls. Literal names are enough + * because the target catalog is already built from parsed `.cs` classes. + */ + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { glob } from 'glob'; +import type { ParsedFile } from 'gitnexus-shared'; +import type { KnowledgeGraph } from '../../../graph/types.js'; +import { createIgnoreFilter } from '../../../../config/ignore-service.js'; +import { generateId } from '../../../../lib/utils.js'; +import { getMaxFileSizeBytes } from '../../utils/max-file-size.js'; +import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js'; +import { resolveDefGraphId } from '../../scope-resolution/graph-bridge/ids.js'; +import { definitionIdPosition } from '../../scope-resolution/utils/definition-id.js'; + +const VIEW_COMPONENT_SUFFIX = 'ViewComponent'; +const VIEW_COMPONENT_TAG_RE = /<\s*vc:([a-z][a-z0-9-]*)\b/gi; +const COMPONENT_NAME_RE = /^[A-Za-z_][A-Za-z0-9_.-]*$/; +const TYPE_MODIFIERS = new Set([ + 'public', + 'internal', + 'protected', + 'private', + 'abstract', + 'sealed', + 'partial', + 'static', + 'new', + 'file', + 'required', + 'unsafe', + 'readonly', +]); +const RAZOR_BLOCK_KEYWORDS = new Set([ + 'if', + 'for', + 'foreach', + 'while', + 'using', + 'switch', + 'try', + 'lock', + 'functions', + 'helper', + 'code', + 'section', + 'do', +]); + +export interface RazorViewComponentConfig { + /** Repo-relative `.cshtml` path → extracted invocation names. */ + readonly views: ReadonlyMap; +} + +export interface ViewComponentAliasBind { + readonly className: string; + /** 1-based line of the type declaration (including leading attributes). */ + readonly startLine: number; + /** 0-based column of the type declaration (including leading attributes). */ + readonly startCol: number; + readonly aliases: readonly string[]; +} + +class SourceCursor { + i = 0; + line = 1; + col = 0; + + constructor(readonly source: string) {} + + get length(): number { + return this.source.length; + } + + get done(): boolean { + return this.i >= this.source.length; + } + + peek(n = 0): string { + return this.source[this.i + n] ?? ''; + } + + startsWith(value: string): boolean { + return this.source.startsWith(value, this.i); + } + + snapshot(): { i: number; line: number; col: number } { + return { i: this.i, line: this.line, col: this.col }; + } + + restore(pos: { i: number; line: number; col: number }): void { + this.i = pos.i; + this.line = pos.line; + this.col = pos.col; + } + + advance(count = 1): void { + const end = Math.min(this.i + count, this.source.length); + while (this.i < end) { + const ch = this.source[this.i]!; + this.i += 1; + if (ch === '\n') { + this.line += 1; + this.col = 0; + } else { + this.col += 1; + } + } + } +} + +function isIdentStart(ch: string): boolean { + return (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || ch === '_' || ch === '@'; +} + +function isIdentPart(ch: string): boolean { + return isIdentStart(ch) || (ch >= '0' && ch <= '9'); +} + +function skipWhitespace(cur: SourceCursor): void { + while (!cur.done) { + const ch = cur.peek(); + if (ch !== ' ' && ch !== '\t' && ch !== '\n' && ch !== '\r' && ch !== '\f' && ch !== '\v') + break; + cur.advance(); + } +} + +/** Skip line comments and block comments. Returns true if a comment was consumed. */ +function skipCsharpComment(cur: SourceCursor): boolean { + if (cur.startsWith('//')) { + while (!cur.done && cur.peek() !== '\n') cur.advance(); + return true; + } + if (cur.startsWith('/*')) { + cur.advance(2); + while (!cur.done && !cur.startsWith('*/')) cur.advance(); + if (cur.startsWith('*/')) cur.advance(2); + return true; + } + return false; +} + +function skipCsharpTrivia(cur: SourceCursor): void { + for (;;) { + skipWhitespace(cur); + if (!skipCsharpComment(cur)) return; + } +} + +function skipRegularString(cur: SourceCursor, interpolated: boolean): void { + cur.advance(); // opening " + while (!cur.done) { + const ch = cur.peek(); + if (ch === '\\') { + cur.advance(2); + continue; + } + if (interpolated && ch === '{') { + if (cur.peek(1) === '{') { + cur.advance(2); + continue; + } + skipInterpolation(cur); + continue; + } + cur.advance(); + if (ch === '"') return; + } +} + +function skipVerbatimString(cur: SourceCursor, interpolated: boolean): void { + cur.advance(2); // @" + while (!cur.done) { + const ch = cur.peek(); + if (ch === '"') { + if (cur.peek(1) === '"') { + cur.advance(2); + continue; + } + cur.advance(); + return; + } + if (interpolated && ch === '{') { + if (cur.peek(1) === '{') { + cur.advance(2); + continue; + } + skipInterpolation(cur); + continue; + } + cur.advance(); + } +} + +function skipRawString(cur: SourceCursor): void { + let quoteCount = 0; + while (cur.peek() === '"') { + quoteCount += 1; + cur.advance(); + } + while (!cur.done) { + if (cur.peek() !== '"') { + cur.advance(); + continue; + } + let seen = 0; + while (cur.peek() === '"') { + seen += 1; + cur.advance(); + } + if (seen >= quoteCount) return; + } +} + +function skipInterpolation(cur: SourceCursor): void { + cur.advance(); // { + let depth = 1; + while (!cur.done && depth > 0) { + skipCsharpTrivia(cur); + if (cur.done) return; + if (skipCsharpString(cur)) continue; + const ch = cur.peek(); + if (ch === '{') depth += 1; + else if (ch === '}') depth -= 1; + cur.advance(); + } +} + +function skipCsharpString(cur: SourceCursor): boolean { + const ch = cur.peek(); + if (ch === "'") { + cur.advance(); + if (cur.peek() === '\\') cur.advance(2); + else cur.advance(); + if (cur.peek() === "'") cur.advance(); + return true; + } + if (ch === '"') { + if (cur.peek(1) === '"' && cur.peek(2) === '"') skipRawString(cur); + else skipRegularString(cur, false); + return true; + } + if (ch === '$' && cur.peek(1) === '@' && cur.peek(2) === '"') { + cur.advance(); + skipVerbatimString(cur, true); + return true; + } + if (ch === '@' && cur.peek(1) === '$' && cur.peek(2) === '"') { + cur.advance(2); + skipVerbatimString(cur, true); + return true; + } + if (ch === '@' && cur.peek(1) === '"') { + skipVerbatimString(cur, false); + return true; + } + if (ch === '$' && cur.peek(1) === '"') { + if (cur.peek(2) === '"' && cur.peek(3) === '"') { + cur.advance(); + skipRawString(cur); + } else { + cur.advance(); + skipRegularString(cur, true); + } + return true; + } + return false; +} + +function readIdent(cur: SourceCursor): string | undefined { + if (!isIdentStart(cur.peek())) return undefined; + const start = cur.i; + if (cur.peek() === '@') cur.advance(); + if (!isIdentStart(cur.peek()) && !(cur.peek() >= 'A' && cur.peek() <= 'z')) { + cur.i = start; + return undefined; + } + while (isIdentPart(cur.peek()) && cur.peek() !== '@') cur.advance(); + const raw = cur.source.slice(start, cur.i); + return raw.startsWith('@') ? raw.slice(1) : raw; +} + +function tryReadIdent(cur: SourceCursor): string | undefined { + skipCsharpTrivia(cur); + return readIdent(cur); +} + +function decodeCsharpString(cur: SourceCursor): string | undefined { + skipCsharpTrivia(cur); + const start = cur.snapshot(); + const ch = cur.peek(); + if (ch === '$') return undefined; + if (ch === '@' && cur.peek(1) === '"') { + cur.advance(2); + let value = ''; + while (!cur.done) { + if (cur.peek() === '"') { + if (cur.peek(1) === '"') { + value += '"'; + cur.advance(2); + continue; + } + cur.advance(); + return value; + } + value += cur.peek(); + cur.advance(); + } + cur.restore(start); + return undefined; + } + if (ch === '"' && cur.peek(1) === '"' && cur.peek(2) === '"') { + let quoteCount = 0; + while (cur.peek() === '"') { + quoteCount += 1; + cur.advance(); + } + const bodyStart = cur.i; + while (!cur.done) { + if (cur.peek() !== '"') { + cur.advance(); + continue; + } + const closeStart = cur.i; + let seen = 0; + while (cur.peek() === '"') { + seen += 1; + cur.advance(); + } + if (seen >= quoteCount) { + return cur.source.slice(bodyStart, closeStart); + } + } + cur.restore(start); + return undefined; + } + if (ch === '"') { + cur.advance(); + let value = ''; + while (!cur.done) { + const next = cur.peek(); + if (next === '\\') { + cur.advance(); + const esc = cur.peek(); + cur.advance(); + const map: Record = { + n: '\n', + r: '\r', + t: '\t', + '"': '"', + '\\': '\\', + '0': '\0', + }; + value += map[esc] ?? esc; + continue; + } + if (next === '"') { + cur.advance(); + return value; + } + value += next; + cur.advance(); + } + cur.restore(start); + return undefined; + } + return undefined; +} + +function skipBalanced(cur: SourceCursor, open: string, close: string): boolean { + skipCsharpTrivia(cur); + if (cur.peek() !== open) return false; + let depth = 0; + while (!cur.done) { + skipCsharpTrivia(cur); + if (cur.done) return false; + if (skipCsharpString(cur)) continue; + const ch = cur.peek(); + if (ch === open) depth += 1; + else if (ch === close) { + depth -= 1; + cur.advance(); + if (depth === 0) return true; + continue; + } + cur.advance(); + } + return false; +} + +function componentNameFromLiteral(value: string | undefined): string | undefined { + if (value === undefined || !COMPONENT_NAME_RE.test(value)) return undefined; + return value; +} + +function isViewComponentAttributeName(name: string): boolean { + return name === 'ViewComponent' || name === 'ViewComponentAttribute'; +} + +function readQualifiedTail(cur: SourceCursor): string | undefined { + skipCsharpTrivia(cur); + let name = readIdent(cur); + if (name === undefined) return undefined; + for (;;) { + skipCsharpTrivia(cur); + if (cur.peek() === '.' || (cur.peek() === ':' && cur.peek(1) === ':')) { + cur.advance(cur.peek() === ':' ? 2 : 1); + skipCsharpTrivia(cur); + const next = readIdent(cur); + if (next === undefined) return name; + name = next; + continue; + } + return name; + } +} + +function readViewComponentNameArgument(cur: SourceCursor): string | undefined { + skipCsharpTrivia(cur); + if (cur.peek() !== '(') return undefined; + cur.advance(); + let alias: string | undefined; + while (!cur.done && cur.peek() !== ')') { + skipCsharpTrivia(cur); + if (cur.peek() === ')') break; + const beforeArg = cur.snapshot(); + const ident = readIdent(cur); + skipCsharpTrivia(cur); + if (ident === 'Name' && cur.peek() === '=') { + cur.advance(); + alias = componentNameFromLiteral(decodeCsharpString(cur)); + } else { + cur.restore(beforeArg); + skipCsharpTrivia(cur); + if (cur.peek() === '"' || cur.peek() === '@') { + // Positional string arguments are not ViewComponentAttribute.Name. + skipCsharpString(cur); + } else if (cur.peek() === '(' || cur.peek() === '[' || cur.peek() === '{') { + const open = cur.peek(); + const close = open === '(' ? ')' : open === '[' ? ']' : '}'; + skipBalanced(cur, open, close); + } else { + while (!cur.done && cur.peek() !== ',' && cur.peek() !== ')') { + if (skipCsharpString(cur)) continue; + if (skipCsharpComment(cur)) continue; + cur.advance(); + } + } + } + skipCsharpTrivia(cur); + if (cur.peek() === ',') cur.advance(); + } + if (cur.peek() === ')') cur.advance(); + return alias; +} + +function collectInvokeAfterIdent( + ident: string, + cur: SourceCursor, + previous: string | undefined, + memberReceiver: string | undefined, + names: Set, +): void { + skipCsharpTrivia(cur); + const hasMvcReceiver = previous !== '.' || memberReceiver === 'this' || memberReceiver === 'base'; + if (ident === 'ViewComponent' && cur.peek() === '(') { + if (previous === '[' || previous === ',' || !hasMvcReceiver) return; + cur.advance(); + const name = componentNameFromLiteral(decodeCsharpString(cur)); + if (name !== undefined) names.add(name); + return; + } + if (ident !== 'Component' || cur.peek() !== '.' || !hasMvcReceiver) return; + const afterDot = cur.snapshot(); + cur.advance(); + skipCsharpTrivia(cur); + if (readIdent(cur) !== 'InvokeAsync') { + cur.restore(afterDot); + return; + } + skipCsharpTrivia(cur); + if (cur.peek() !== '(') return; + cur.advance(); + const name = componentNameFromLiteral(decodeCsharpString(cur)); + if (name !== undefined) names.add(name); +} + +/** In-repo C# `Component.InvokeAsync("X")` / `ViewComponent("X")` literals. */ +export function extractCsharpViewComponentInvocations(source: string): string[] { + if (!source.includes('ViewComponent') && !source.includes('InvokeAsync')) return []; + const names = new Set(); + const cur = new SourceCursor(source); + let previous: string | undefined; + let memberReceiver: string | undefined; + let squareDepth = 0; + while (!cur.done) { + skipCsharpTrivia(cur); + if (cur.done) break; + if (skipCsharpString(cur)) { + previous = 'string'; + continue; + } + const ident = readIdent(cur); + if (ident !== undefined) { + const inAttribute = squareDepth > 0; + collectInvokeAfterIdent(ident, cur, inAttribute ? '[' : previous, memberReceiver, names); + previous = ident; + memberReceiver = undefined; + continue; + } + const ch = cur.peek(); + if (ch === '[') squareDepth += 1; + else if (ch === ']' && squareDepth > 0) squareDepth -= 1; + memberReceiver = ch === '.' ? previous : undefined; + previous = ch; + cur.advance(); + } + return [...names]; +} + +function parseAttributeListBody(cur: SourceCursor): string[] { + const aliases: string[] = []; + skipCsharpTrivia(cur); + const specifier = cur.snapshot(); + const specifierName = readIdent(cur); + skipCsharpTrivia(cur); + if (specifierName !== undefined && cur.peek() === ':' && cur.peek(1) !== ':') { + cur.advance(); + } else { + cur.restore(specifier); + } + while (!cur.done && cur.peek() !== ']') { + skipCsharpTrivia(cur); + if (cur.peek() === ']') break; + const tail = readQualifiedTail(cur); + skipCsharpTrivia(cur); + if (tail !== undefined && isViewComponentAttributeName(tail) && cur.peek() === '(') { + const alias = readViewComponentNameArgument(cur); + if (alias !== undefined) aliases.push(alias); + } else if (cur.peek() === '(') { + skipBalanced(cur, '(', ')'); + } + skipCsharpTrivia(cur); + if (cur.peek() === ',') cur.advance(); + else break; + } + if (cur.peek() === ']') cur.advance(); + return aliases; +} + +/** + * Explicit `[ViewComponent(Name = "...")]` aliases keyed to the following + * class declaration. Positional constructor arguments are ignored: the MVC + * attribute only exposes `Name` as a property. + */ +export function extractViewComponentAliasBinds(source: string): ViewComponentAliasBind[] { + if (!source.includes('ViewComponent')) return []; + const binds: ViewComponentAliasBind[] = []; + const cur = new SourceCursor(source); + const pending: { startLine: number; startCol: number; aliases: string[] }[] = []; + + const flushPending = (className: string, startLine: number, startCol: number): void => { + const aliases = pending.flatMap((entry) => entry.aliases); + const start = pending[0]; + binds.push({ + className, + startLine: start?.startLine ?? startLine, + startCol: start?.startCol ?? startCol, + aliases: [...new Set(aliases)], + }); + pending.length = 0; + }; + + while (!cur.done) { + skipCsharpTrivia(cur); + if (cur.done) break; + if (skipCsharpString(cur)) continue; + const startLine = cur.line; + const startCol = cur.col; + if (cur.peek() === '[') { + cur.advance(); + const aliases = parseAttributeListBody(cur); + pending.push({ startLine, startCol, aliases }); + continue; + } + const ident = readIdent(cur); + if (ident === undefined) { + pending.length = 0; + cur.advance(); + continue; + } + if (TYPE_MODIFIERS.has(ident)) continue; + if (ident === 'class' || ident === 'record') { + let className = tryReadIdent(cur); + if (ident === 'record' && (className === 'class' || className === 'struct')) { + className = tryReadIdent(cur); + } + if (className !== undefined && pending.some((entry) => entry.aliases.length > 0)) { + flushPending(className, startLine, startCol); + } else { + pending.length = 0; + } + continue; + } + pending.length = 0; + } + return binds; +} + +/** Extract explicit `[ViewComponent(Name = "...")]` aliases by class name. */ +export function extractViewComponentAliases( + source: string, +): ReadonlyMap { + const aliases = new Map(); + for (const bind of extractViewComponentAliasBinds(source)) { + if (bind.aliases.length === 0) continue; + const existing = aliases.get(bind.className); + if (existing) { + for (const alias of bind.aliases) { + if (!existing.includes(alias)) existing.push(alias); + } + } else { + aliases.set(bind.className, [...bind.aliases]); + } + } + return aliases; +} + +function tagNameToComponentName(tagName: string): string { + return tagName + .split('-') + .filter(Boolean) + .map((part) => part[0]!.toUpperCase() + part.slice(1)) + .join(''); +} + +function collectVcTags(span: string, names: Set): void { + VIEW_COMPONENT_TAG_RE.lastIndex = 0; + for (const match of span.matchAll(VIEW_COMPONENT_TAG_RE)) { + names.add(tagNameToComponentName(match[1]!)); + } +} + +function skipRazorComment(cur: SourceCursor): boolean { + if (!cur.startsWith('@*')) return false; + cur.advance(2); + while (!cur.done && !cur.startsWith('*@')) cur.advance(); + if (cur.startsWith('*@')) cur.advance(2); + return true; +} + +function countAtRun(cur: SourceCursor): number { + let count = 0; + while (cur.peek() === '@') { + count += 1; + cur.advance(); + } + return count; +} + +function scanCsharpSpan(span: string, names: Set): void { + for (const name of extractCsharpViewComponentInvocations(span)) names.add(name); +} + +function skipOptionalParens(cur: SourceCursor): void { + skipWhitespace(cur); + if (cur.peek() === '(') skipBalanced(cur, '(', ')'); +} + +function consumeRazorCodeBlock(cur: SourceCursor, names: Set): void { + skipCsharpTrivia(cur); + skipOptionalParens(cur); + skipCsharpTrivia(cur); + if (cur.peek() !== '{') { + const start = cur.i; + while (!cur.done && cur.peek() !== '\n' && cur.peek() !== '{') { + if (skipCsharpString(cur) || skipCsharpComment(cur)) continue; + cur.advance(); + } + scanCsharpSpan(cur.source.slice(start, cur.i), names); + if (cur.peek() === '{') consumeRazorCodeBlock(cur, names); + return; + } + const bodyStart = cur.i + 1; + if (!skipBalanced(cur, '{', '}')) return; + scanCsharpSpan(cur.source.slice(bodyStart, cur.i - 1), names); +} + +function consumeImplicitExpression(cur: SourceCursor, names: Set): void { + const start = cur.i; + skipCsharpTrivia(cur); + if (cur.peek() === '(') { + const innerStart = cur.i + 1; + if (skipBalanced(cur, '(', ')')) { + scanCsharpSpan(cur.source.slice(innerStart, cur.i - 1), names); + } + return; + } + // Implicit expressions: `@await Component.InvokeAsync("X")` / `@Component.InvokeAsync(...)`. + while (!cur.done) { + skipCsharpTrivia(cur); + if (cur.done) break; + if (skipCsharpString(cur)) continue; + if (cur.peek() === '(') { + skipBalanced(cur, '(', ')'); + continue; + } + if (cur.peek() === '{') { + skipBalanced(cur, '{', '}'); + continue; + } + const ch = cur.peek(); + if (ch === '<' || ch === '\n') break; + if (ch === '@') break; + if (!isIdentPart(ch) && ch !== '.' && ch !== '?') { + if (ch === ';') cur.advance(); + break; + } + cur.advance(); + } + scanCsharpSpan(cur.source.slice(start, cur.i), names); +} + +function consumeRazorTransition(cur: SourceCursor, names: Set): void { + skipWhitespace(cur); + if (cur.peek() === '{') { + consumeRazorCodeBlock(cur, names); + return; + } + if (cur.peek() === '(') { + consumeImplicitExpression(cur, names); + return; + } + const identStart = cur.snapshot(); + const ident = readIdent(cur); + if (ident === undefined) { + consumeImplicitExpression(cur, names); + return; + } + if (ident === 'await' || ident === 'Component') { + cur.restore(identStart); + consumeImplicitExpression(cur, names); + return; + } + if (RAZOR_BLOCK_KEYWORDS.has(ident)) { + if (ident === 'section' || ident === 'helper') tryReadIdent(cur); + consumeRazorCodeBlock(cur, names); + return; + } + cur.restore(identStart); + consumeImplicitExpression(cur, names); +} + +/** Extract statically resolvable ViewComponent names from one Razor template. */ +export function extractRazorViewComponentInvocations(source: string): string[] { + // Most views do not invoke a ViewComponent. Avoid the character-by-character + // Razor scan unless one of the two supported invocation spellings is present. + // This is only a coarse gate; the state machine below still decides whether a + // token is executable markup/C# or a comment/string/escaped transition. + if (!source.includes('InvokeAsync') && !/<\s*vc:/i.test(source)) return []; + + const names = new Set(); + const cur = new SourceCursor(source); + let markupStart = 0; + const flushMarkup = (): void => { + if (cur.i > markupStart) collectVcTags(source.slice(markupStart, cur.i), names); + }; + + while (!cur.done) { + if (cur.peek() !== '@') { + cur.advance(); + continue; + } + flushMarkup(); + if (skipRazorComment(cur)) { + markupStart = cur.i; + continue; + } + const atCount = countAtRun(cur); + const leftover = atCount % 2; + if (leftover === 0) { + markupStart = cur.i; + continue; + } + consumeRazorTransition(cur, names); + markupStart = cur.i; + } + flushMarkup(); + return [...names]; +} + +/** + * Read Razor views once per C# resolution pass. The same ignore rules and file + * size ceiling as repository scanning are applied, and edge emission later + * additionally requires a live File node. This prevents ignored, oversized, + * or concurrently removed templates from entering the graph. + */ +export async function loadRazorViewComponentConfig( + repoRoot: string, +): Promise { + const ignore = await createIgnoreFilter(repoRoot); + const paths = await glob('**/*.cshtml', { + cwd: repoRoot, + nodir: true, + dot: false, + ignore, + }); + paths.sort(); + + const maxBytes = getMaxFileSizeBytes(); + const views = new Map(); + for (const rawPath of paths) { + const filePath = rawPath.replace(/\\/g, '/'); + // The size gate and the read go through one handle so both observe the same + // inode. Re-resolving the path for the read would let a template swapped in + // between them be read unchecked (CodeQL js/file-system-race). + let handle: fs.FileHandle | undefined; + try { + handle = await fs.open(path.join(repoRoot, filePath), 'r'); + const stat = await handle.stat(); + if (!stat.isFile() || stat.size > maxBytes) continue; + const source = await handle.readFile('utf8'); + views.set(filePath, extractRazorViewComponentInvocations(source)); + } catch { + // A view may disappear between glob/open/read during watch mode. + } finally { + await handle?.close().catch(() => {}); + } + } + return { views }; +} + +function addCandidate( + candidates: Map>, + invocationName: string, + targetId: string, +): void { + const key = invocationName.toLocaleLowerCase('en-US'); + const existing = candidates.get(key); + if (existing) { + existing.add(targetId); + } else { + candidates.set(key, new Set([targetId])); + } +} + +function bindAliasesForClass( + binds: readonly ViewComponentAliasBind[], + className: string, + nodeId: string, + filePath: string, +): readonly string[] | undefined { + const matches = binds.filter((bind) => bind.className === className); + if (matches.length === 0) return undefined; + if (matches.length === 1) return matches[0]!.aliases; + const pos = definitionIdPosition(nodeId, filePath); + if (pos === undefined) return undefined; + const atPosition = matches.filter( + (bind) => bind.startLine === pos.line && bind.startCol === pos.column, + ); + if (atPosition.length === 1) return atPosition[0]!.aliases; + return undefined; +} + +/** + * Emit workspace File → in-repo ViewComponent Class CALLS edges. + * + * Targets are only Class nodes produced from this repo's `.cs` files. There is + * no lookup of ASP.NET SDK types; `: ViewComponent` in source is a naming + * hint, not a resolved EXTENDS edge to `Microsoft.AspNetCore.Mvc.ViewComponent`. + * + * Ambiguous component names fail closed: two in-repo classes claiming the + * same name is not evidence for picking either one. + */ +export function emitRazorViewComponentEdges( + graph: KnowledgeGraph, + parsedFiles: readonly ParsedFile[], + nodeLookup: GraphNodeLookup, + config: RazorViewComponentConfig | undefined, + csharpSources: ReadonlyMap, +): void { + if (!config) return; + + const candidates = new Map>(); + for (const parsed of parsedFiles) { + if (!parsed.filePath.endsWith('.cs')) continue; + const source = csharpSources.get(parsed.filePath) ?? ''; + const binds = source.includes('ViewComponent') ? extractViewComponentAliasBinds(source) : []; + for (const def of parsed.localDefs) { + if (def.type !== 'Class') continue; + const className = def.qualifiedName?.split('.').pop() ?? def.nodeId.split(':').pop() ?? ''; + const conventionalName = className.endsWith(VIEW_COMPONENT_SUFFIX) + ? className.slice(0, -VIEW_COMPONENT_SUFFIX.length) + : undefined; + const explicitAliases = bindAliasesForClass(binds, className, def.nodeId, parsed.filePath); + if (!conventionalName && (explicitAliases === undefined || explicitAliases.length === 0)) { + continue; + } + + const targetId = resolveDefGraphId(parsed.filePath, def, nodeLookup); + if (!targetId || !graph.getNode(targetId)) continue; + // An explicit [ViewComponent(Name = "...")] replaces the suffix name, + // matching ASP.NET. Never register the SDK base type as a candidate. + if (explicitAliases !== undefined && explicitAliases.length > 0) { + for (const alias of explicitAliases) addCandidate(candidates, alias, targetId); + } else if (conventionalName) { + addCandidate(candidates, conventionalName, targetId); + } + } + } + + const emitFromFile = (filePath: string, invocationNames: readonly string[]): void => { + const sourceId = generateId('File', filePath); + if (!graph.getNode(sourceId)) return; + for (const invocationName of invocationNames) { + const matches = candidates.get(invocationName.toLocaleLowerCase('en-US')); + if (!matches || matches.size !== 1) continue; + const targetId = matches.values().next().value; + if (typeof targetId !== 'string' || !graph.getNode(targetId)) continue; + graph.addRelationship({ + id: generateId('CALLS', `${sourceId}:razor-view-component:${targetId}`), + sourceId, + targetId, + type: 'CALLS', + confidence: 0.9, + reason: 'aspnet-razor-view-component', + }); + } + }; + + for (const [viewPath, invocationNames] of config.views) { + emitFromFile(viewPath, invocationNames); + } + for (const [filePath, source] of csharpSources) { + if (!filePath.endsWith('.cs')) continue; + if (!source.includes('ViewComponent') && !source.includes('InvokeAsync')) continue; + emitFromFile(filePath, extractCsharpViewComponentInvocations(source)); + } +} diff --git a/gitnexus/src/core/ingestion/languages/csharp/resolution-config.ts b/gitnexus/src/core/ingestion/languages/csharp/resolution-config.ts index 9ea232c05..714be8c1f 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/resolution-config.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/resolution-config.ts @@ -12,19 +12,29 @@ import { type CSharpProjectConfig, type CSharpNamespaceEvidence, } from '../../language-config.js'; +import { + loadRazorViewComponentConfig, + type RazorViewComponentConfig, +} from './razor-view-components.js'; export interface CsharpResolutionConfig { readonly csharpConfigs: readonly CSharpProjectConfig[]; /** In-repo declared-namespace evidence gating suffix-fallback resolution (#1881). */ readonly namespaces?: CSharpNamespaceEvidence; + /** Razor views scanned for ASP.NET ViewComponent invocation conventions. */ + readonly razorViewComponents?: RazorViewComponentConfig; } export async function loadCsharpResolutionConfig( repoRoot: string, ): Promise { - const scan = await scanCSharpProject(repoRoot); + const [scan, razorViewComponents] = await Promise.all([ + scanCSharpProject(repoRoot), + loadRazorViewComponentConfig(repoRoot), + ]); return { csharpConfigs: scan.configs, namespaces: csharpScanToEvidence(scan), + razorViewComponents, }; } diff --git a/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts index eb1d3b10d..6d200de9b 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts @@ -22,6 +22,7 @@ import { import { populateCsharpNamespaceSiblings } from './namespace-siblings.js'; import { loadCsharpResolutionConfig, type CsharpResolutionConfig } from './resolution-config.js'; import { unwrapCsharpElementType } from './accessor-unwrap.js'; +import { emitRazorViewComponentEdges } from './razor-view-components.js'; const csharpScopeResolver: ScopeResolver = { // Construction is keyword-prefixed: `new Service(db).doWork()` (#2708). @@ -102,6 +103,96 @@ const csharpScopeResolver: ScopeResolver = { // files. The compound-receiver walker needs to walk up from the // class scope to find them; see the contract field for rationale. hoistTypeBindingsToModule: true, + + // `IValidator` and `IValidator` are one instantiation, so the + // dispatch fan-out must not read them as two (#2912). See the alias table. + normalizeTypeArgument: normalizeCsharpTypeArgument, + + // Razor views stay out of the C# parser. Bind literal ViewComponent names + // only onto in-repo classes (Spring-style: skip the SDK type, hop to the + // workspace implementor). + emitPostResolutionEdges: (graph, parsedFiles, nodeLookup, _indexes, ctx) => { + const config = ctx.resolutionConfig as CsharpResolutionConfig | undefined; + emitRazorViewComponentEdges( + graph, + parsedFiles, + nodeLookup, + config?.razorViewComponents, + ctx.fileContents, + ); + }, }; +/** + * C# predefined type aliases — the 15 keywords the language defines as exact + * synonyms for `System` types (`string` ≡ `System.String`), plus `nint`/`nuint`. + * A codebase mixing the spellings is common enough that StyleCop ships a rule + * about it (SA1121), so the two forms genuinely meet across files. + * + * Keyword → BCL simple name; anything else is returned unchanged, including the + * BCL names themselves (already canonical) and any qualified spelling, which is + * compared as written. + * + * A workspace may legally declare its OWN type named `String`, which shadows the + * BCL simple name; this table then reads `IValidator` as the `string` + * instantiation and KEEPS that implementor in the fan-out. Deliberate, and the + * safe direction: the alternative is pruning on the belief that two spellings + * differ, which is the missing-edge failure `generic-instantiation.ts` is built + * to avoid. Resolving instead of normalizing cannot settle it either — the + * identity comparison needs a `definitionId` from BOTH sides, and a built-in + * name has none, so "built-in versus workspace-declared" would be a new prune + * with no positive evidence behind it. The result is one surplus edge in a + * shape that is rare on its own terms, i.e. exactly the pre-#2912 fan-out for + * that pair and no worse. + */ +const CSHARP_PREDEFINED_TYPE_ALIASES: ReadonlyMap = new Map([ + ['bool', 'Boolean'], + ['byte', 'Byte'], + ['sbyte', 'SByte'], + ['char', 'Char'], + ['decimal', 'Decimal'], + ['double', 'Double'], + ['float', 'Single'], + ['int', 'Int32'], + ['uint', 'UInt32'], + ['long', 'Int64'], + ['ulong', 'UInt64'], + ['short', 'Int16'], + ['ushort', 'UInt16'], + ['nint', 'IntPtr'], + ['nuint', 'UIntPtr'], + ['object', 'Object'], + ['string', 'String'], +]); + +/** The BCL simple names the keywords alias. A spelling that reduces to one of + * these IS the predefined type; anything else that merely happens to sit in + * `System` is an ordinary type and keeps its qualifier. */ +const CSHARP_PREDEFINED_TYPE_NAMES: ReadonlySet = new Set( + CSHARP_PREDEFINED_TYPE_ALIASES.values(), +); + +const CSHARP_SYSTEM_QUALIFIER = /^(?:global::)?System\./; + +function normalizeCsharpTypeArgument(name: string): string { + const named = name.trim(); + // A keyword answers immediately: `string` → `String`. + const aliased = CSHARP_PREDEFINED_TYPE_ALIASES.get(named); + if (aliased !== undefined) return aliased; + // Otherwise the `System.` qualifier is dropped so the fully-qualified + // spelling of a predefined type meets that keyword: `System.String` → + // `String` ≡ `string` → `String`. The optional `global::` alias qualifier goes + // with it — `import-decomposer` already unwraps that spelling elsewhere, and + // leaving it on would make `global::System.String` unequal to `string` and + // prune a live implementor. + // + // ONLY when what remains is a predefined type. `System.Custom` is an ordinary + // type that happens to live in `System`, and answering `Custom` for it would + // equate it with an unrelated `Custom` elsewhere in the workspace. Returned as + // written instead, which sends it to the identity comparison — the step that + // can actually tell two declarations apart. + const bare = named.replace(CSHARP_SYSTEM_QUALIFIER, ''); + return bare !== named && CSHARP_PREDEFINED_TYPE_NAMES.has(bare) ? bare : named; +} + export { csharpScopeResolver }; diff --git a/gitnexus/src/core/ingestion/languages/dart/captures.ts b/gitnexus/src/core/ingestion/languages/dart/captures.ts index 351eaee7d..a6c5ef773 100644 --- a/gitnexus/src/core/ingestion/languages/dart/captures.ts +++ b/gitnexus/src/core/ingestion/languages/dart/captures.ts @@ -1069,9 +1069,15 @@ function emitHeritage(classNode: SyntaxNode, out: CaptureMatch[]): void { for (let i = 0; i < superclass.namedChildCount; i++) { const c = superclass.namedChild(i); if (c !== null && c.type === 'type_identifier') { + // `extends Base` spells the arguments in a SIBLING node, so the + // anchor's own text cannot carry them; the sub-tag does (#2912). + const args = typeArgumentsAfter(superclass, i); out.push({ '@reference.inherits': nodeToCapture('@reference.inherits', c), '@reference.name': nodeToCapture('@reference.name', c), + ...(args === null + ? {} + : { '@reference.type-arguments': nodeToCapture('@reference.type-arguments', args) }), }); break; } @@ -1144,7 +1150,26 @@ function emitHeritageMarkers( for (let i = 0; i < container.namedChildCount; i++) { const c = container.namedChild(i); if (c === null || c.type !== 'type_identifier') continue; - const payload = encodeMarker('heritage', [kind, c.text, className]); + // `implements Validator` / `with M`: the arguments ride the + // marker payload, because this heritage never becomes a reference SITE — + // `emitDartHeritageEdges` reads the marker and emits the edge (#2912). + // Dropped rather than encoded when the spelling contains the marker's own + // ':' delimiter, which `encodeMarker` rejects outright; absence is the + // fail-open value everywhere this is read. + const args = typeArgumentsAfter(container, i)?.text; + const fields = + args === undefined || args.includes(':') + ? [kind, c.text, className] + : [kind, c.text, className, args]; + const payload = encodeMarker('heritage', fields); out.push({ '@import.heritage': syntheticCapture('@import.heritage', c, payload) }); } } + +/** The `type_arguments` node written immediately after `container`'s named + * child at `index` — the arguments of the type that child names — or `null` + * when that type was written without any. */ +function typeArgumentsAfter(container: SyntaxNode, index: number): SyntaxNode | null { + const next = container.namedChild(index + 1); + return next !== null && next.type === 'type_arguments' ? next : null; +} diff --git a/gitnexus/src/core/ingestion/languages/dart/import-target.ts b/gitnexus/src/core/ingestion/languages/dart/import-target.ts index 443edfcbb..fd6c5224a 100644 --- a/gitnexus/src/core/ingestion/languages/dart/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/dart/import-target.ts @@ -13,8 +13,57 @@ * `targetRaw` arrives already quote-stripped from `interpretDartImport`. */ +import { perFileSet } from '../../import-resolvers/per-file-set.js'; import { DART_HERITAGE_PREFIX } from './interpret.js'; +/** + * Basename → files carrying it, in `allFilePaths` iteration order, memoized on + * the Set's identity (#2879). + * + * Both resolution legs answered `fp === candidate || fp.endsWith('/' + candidate)` + * with a full workspace scan, and the `package:` leg ran one scan PER candidate + * — for an external package both candidates miss, so both scans always ran to + * completion. The orchestrator passes the same Set to every import in a pass, + * so the index is built once per run. + * + * Bucketing by basename is exact rather than a heuristic: a path satisfying + * either arm of the match ends with `candidate`, so its last `/`-delimited + * segment is `candidate`'s. Paths are indexed RAW, without slash normalization, + * because the scans this replaces compared raw paths too — normalizing here + * would start resolving backslash paths that previously returned null. + */ +interface DartFileIndex { + readonly byBasename: Map; +} + +const getDartFileIndex = perFileSet((allFilePaths: ReadonlySet): DartFileIndex => { + const byBasename = new Map(); + for (const fp of allFilePaths) { + const base = fp.slice(fp.lastIndexOf('/') + 1); + let bucket = byBasename.get(base); + if (bucket === undefined) { + bucket = []; + byBasename.set(base, bucket); + } + bucket.push(fp); + } + return { byBasename }; +}); + +/** First file (in Set-iteration order) that IS `candidate` or ends with + * `/` — the exact predicate of the scans this replaces. */ +function findByPathSuffix(allFilePaths: ReadonlySet, candidate: string): string | null { + const bucket = getDartFileIndex(allFilePaths).byBasename.get( + candidate.slice(candidate.lastIndexOf('/') + 1), + ); + if (bucket === undefined) return null; + const suffix = '/' + candidate; + for (const fp of bucket) { + if (fp === candidate || fp.endsWith(suffix)) return fp; + } + return null; +} + /** Resolve a relative path against the importer's directory, normalizing * `.`/`..` segments, then confirm it exists in the workspace file set. */ function resolveRelative( @@ -33,10 +82,7 @@ function resolveRelative( const target = parts.join('/'); if (allFilePaths.has(target)) return target; // Suffix fallback for absolute/rooted workspace paths. - for (const fp of allFilePaths) { - if (fp === target || fp.endsWith('/' + target)) return fp; - } - return null; + return findByPathSuffix(allFilePaths, target); } export function resolveDartImportTarget( @@ -56,10 +102,10 @@ export function resolveDartImportTarget( const slash = targetRaw.indexOf('/'); if (slash === -1) return null; const relPath = targetRaw.slice(slash + 1); + // Candidate priority is load-bearing: `lib/` before bare ``. for (const candidate of [`lib/${relPath}`, relPath]) { - for (const fp of allFilePaths) { - if (fp === candidate || fp.endsWith('/' + candidate)) return fp; - } + const hit = findByPathSuffix(allFilePaths, candidate); + if (hit !== null) return hit; } return null; // external package } diff --git a/gitnexus/src/core/ingestion/languages/dart/query.ts b/gitnexus/src/core/ingestion/languages/dart/query.ts index 38496f2ec..fb93f5fb8 100644 --- a/gitnexus/src/core/ingestion/languages/dart/query.ts +++ b/gitnexus/src/core/ingestion/languages/dart/query.ts @@ -42,7 +42,15 @@ const DART_SCOPE_QUERY = ` (enum_declaration) @scope.class ; ── Declarations — types ───────────────────────────────────────────────────── -(class_definition name: (identifier) @declaration.name) @declaration.class +; The type-parameter list is matched as an UNNAMED optional child: the Dart +; grammar hangs \`type_parameters\` off \`class_definition\` without a field name. +; Recording it is what lets instantiation-aware interface dispatch tell a type +; VARIABLE (\`class Box implements Validator\`) from a concrete argument +; (\`class V implements Validator\`) — see #2912; absent parameters are +; indistinguishable from a language that captures none, and read as unknown. +(class_definition + name: (identifier) @declaration.name + (type_parameters)? @declaration.type-parameters) @declaration.class (mixin_declaration (identifier) @declaration.name) @declaration.trait (extension_declaration name: (identifier) @declaration.name) @declaration.class (enum_declaration name: (identifier) @declaration.name) @declaration.enum diff --git a/gitnexus/src/core/ingestion/languages/dart/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/dart/scope-resolver.ts index 22e1171d1..76bec5d74 100644 --- a/gitnexus/src/core/ingestion/languages/dart/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/dart/scope-resolver.ts @@ -38,6 +38,8 @@ import { generateId } from '../../../../lib/utils.js'; import { dartProvider } from '../dart.js'; import { dartArityCompatibility, dartMergeBindings, resolveDartImportTarget } from './index.js'; import { decodeMarker } from '../../utils/heritage-marker.js'; +import { typeApplicationArguments } from '../../utils/template-arguments.js'; +import type { HeritageTypeArgumentSink } from '../../scope-resolution/utils/generic-instantiation.js'; import { expandDartWildcardNames } from './expand-wildcards.js'; interface ClassDefRef { @@ -77,6 +79,7 @@ function emitDartHeritageEdges( graph: KnowledgeGraph, parsedFiles: readonly ParsedFile[], nodeLookup: GraphNodeLookup, + recordTypeArguments?: HeritageTypeArgumentSink, ): void { const defsByName = new Map(); for (const parsed of parsedFiles) { @@ -110,10 +113,19 @@ function emitDartHeritageEdges( if (decoded?.kind !== 'heritage') continue; const parts = decoded.fields; if (parts.length < 3) continue; - const [kind, baseName, childName] = parts; + const [kind, baseName, childName, rawTypeArguments] = parts; const childId = pickClassByName(childName!, parsed.filePath, defsByName); const baseId = pickClassByName(baseName!, parsed.filePath, defsByName); if (childId === undefined || baseId === undefined || childId === baseId) continue; + // The instantiation this clause was written with — `implements + // Validator` (#2912). Recorded before the dedup below, since the + // FIRST writer wins on both sides and an edge deduped here still needs + // its arguments. A marker from a pre-#2912 cache has no fourth field, + // which reads as unknown. + if (rawTypeArguments !== undefined) { + const typeArguments = typeApplicationArguments(rawTypeArguments); + if (typeArguments !== undefined) recordTypeArguments?.(childId, baseId, typeArguments); + } const key = `${childId}->${baseId}:${kind}`; if (emitted.has(key)) continue; emitted.add(key); @@ -211,8 +223,8 @@ export const dartScopeResolver: ScopeResolver = { // `implements` / `with` IMPLEMENTS edges (extends rides the generic // inherits pre-pass; these need an explicit, kind-independent edge type). - emitHeritageEdges: (graph, parsedFiles, nodeLookup) => - emitDartHeritageEdges(graph, parsedFiles, nodeLookup), + emitHeritageEdges: (graph, parsedFiles, nodeLookup, _scopes, recordTypeArguments) => + emitDartHeritageEdges(graph, parsedFiles, nodeLookup, recordTypeArguments), // Dart is statically typed — the field-fallback heuristic over-connects. fieldFallbackOnMethodLookup: false, diff --git a/gitnexus/src/core/ingestion/languages/go/import-target.ts b/gitnexus/src/core/ingestion/languages/go/import-target.ts index 28e8113fd..14d9e2d48 100644 --- a/gitnexus/src/core/ingestion/languages/go/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/go/import-target.ts @@ -1,4 +1,11 @@ import type { GoModuleConfig } from '../../language-config.js'; +import { + buildPackageDirIndex, + filesDirectlyInPkgDir, + sortedRootFiles, + type PackageDirIndex, +} from '../../import-resolvers/package-dir-index.js'; +import { perFileSet } from '../../import-resolvers/per-file-set.js'; /** * Resolve a Go import path to ALL .go files in the matching package directory. @@ -9,9 +16,9 @@ import type { GoModuleConfig } from '../../language-config.js'; * IMPORTS edge fanout AND binding materialization for every exported symbol in * the package. * - * Strategy (first match wins): - * 1. go.mod-based: strip module prefix, match package directory - * 2. Non-go.mod / GOPATH: progressively shorter directory suffixes + * Strategy: + * 1. With go.mod: resolve only imports owned by that module + * 2. Without go.mod / GOPATH: progressively shorter directory suffixes */ export function resolveGoImportTarget( targetRaw: string, @@ -23,11 +30,14 @@ export function resolveGoImportTarget( const goModule = resolutionConfig as GoModuleConfig | undefined; - // 1) go.mod-based: strip module prefix, match directory - if ( - goModule != null && - (targetRaw === goModule.modulePath || targetRaw.startsWith(`${goModule.modulePath}/`)) - ) { + // 1) go.mod is authoritative: only this module's exact path or subpackages + // can name files in the workspace. Standard-library and third-party + // imports must not fall through to the suffix matcher below. + if (goModule != null) { + const ownedByModule = + targetRaw === goModule.modulePath || targetRaw.startsWith(`${goModule.modulePath}/`); + if (!ownedByModule) return null; + const relativePkg = targetRaw === goModule.modulePath ? '' : targetRaw.slice(goModule.modulePath.length + 1); // e.g. "internal/models" const files = @@ -35,6 +45,7 @@ export function resolveGoImportTarget( ? findRootPackageFiles(allFilePaths) : findAllFilesInPkgDir(allFilePaths, relativePkg); if (files.length > 0) return files; + return null; } // 2) Non-go.mod / GOPATH: progressively shorter directory suffixes. @@ -50,34 +61,34 @@ export function resolveGoImportTarget( return null; } +/** Go packages exclude `_test.go` files: they are a separate package. */ +function isGoPackageFile(normalized: string): boolean { + return normalized.endsWith('.go') && !normalized.endsWith('_test.go'); +} + +/** + * Package index over the file set, memoized on the Set's identity (#2877). + * + * Every leg above used to walk all of `allFilePaths`, and the GOPATH fallback + * walks once per path segment — so without go.mod a single unresolved stdlib or + * third-party import ran the whole cascade before returning null and cost + * several full workspace scans, making resolution O(imports × files). + * + * The orchestrator hands the same Set to every import in a pass, so the index + * is built once per run. `resolveGoImportTarget` must therefore never copy the + * Set before this point — see `import-resolvers/workspace-file-index.ts`. + */ +const getGoPackageIndex = perFileSet( + (allFilePaths: ReadonlySet): PackageDirIndex => + buildPackageDirIndex(allFilePaths, isGoPackageFile), +); + function findRootPackageFiles(allFilePaths: ReadonlySet): string[] { - const result: string[] = []; - for (const raw of allFilePaths) { - const normalized = raw.replace(/\\/g, '/'); - if (normalized.includes('/')) continue; - if (!normalized.endsWith('.go') || normalized.endsWith('_test.go')) continue; - result.push(raw); - } - return result.sort(); + return sortedRootFiles(getGoPackageIndex(allFilePaths)); } function findAllFilesInPkgDir(allFilePaths: ReadonlySet, pkgPath: string): string[] { - const pkgDir = '/' + pkgPath + '/'; - const result: string[] = []; - for (const raw of allFilePaths) { - const normalized = '/' + raw.replace(/\\/g, '/'); - if (!normalized.includes(pkgDir)) continue; - if (!normalized.endsWith('.go') || normalized.endsWith('_test.go')) continue; - // Ensure file is directly in the package directory (not a subdirectory) - const afterPkg = normalized.substring(normalized.indexOf(pkgDir) + pkgDir.length); - if (!afterPkg.includes('/')) result.push(raw); - } - return result; -} - -/** Preserved for backward compat. */ -export interface GoResolveContext { - readonly fromFile: string; - readonly allFilePaths: ReadonlySet; - readonly goModule?: GoModuleConfig; + // Deliberately UNSORTED, unlike the root leg: the previous single-pass scan + // emitted in Set-iteration order and `filesDirectlyInPkgDir` reproduces it. + return filesDirectlyInPkgDir(getGoPackageIndex(allFilePaths), pkgPath); } diff --git a/gitnexus/src/core/ingestion/languages/go/index.ts b/gitnexus/src/core/ingestion/languages/go/index.ts index a71cba2fc..c9d5271c8 100644 --- a/gitnexus/src/core/ingestion/languages/go/index.ts +++ b/gitnexus/src/core/ingestion/languages/go/index.ts @@ -10,7 +10,7 @@ export { synthesizeGoTypeBindings } from './type-binding.js'; export { goArityCompatibility } from './arity.js'; export { goMergeBindings } from './merge-bindings.js'; export { goBindingScopeFor, goImportOwningScope, goReceiverBinding } from './simple-hooks.js'; -export { resolveGoImportTarget, type GoResolveContext } from './import-target.js'; +export { resolveGoImportTarget } from './import-target.js'; export { populateGoPackageSiblings } from './package-siblings.js'; export { populateGoRangeBindings } from './range-binding.js'; export { detectGoInterfaceImplementations } from './interface-impls.js'; diff --git a/gitnexus/src/core/ingestion/languages/go/interface-impls.ts b/gitnexus/src/core/ingestion/languages/go/interface-impls.ts index 5f56345aa..913ff37f1 100644 --- a/gitnexus/src/core/ingestion/languages/go/interface-impls.ts +++ b/gitnexus/src/core/ingestion/languages/go/interface-impls.ts @@ -1,4 +1,8 @@ import type { ParsedFile, ReferenceSite, SymbolDefinition } from 'gitnexus-shared'; +import type { + StructuralImplementationResult, + UndecidedSatisfaction, +} from '../../scope-resolution/contract/scope-resolver.js'; import type { SemanticModel } from '../../model/semantic-model.js'; import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; import { simpleQualifiedName } from '../../scope-resolution/graph-bridge/ids.js'; @@ -29,6 +33,23 @@ type EmbeddedParent = { readonly structId: string; readonly asPointer: boolean } type DualMethodSet = { readonly value: MutableMethodSet; readonly pointer: MutableMethodSet }; /** Which method set satisfied an interface. `value` implies pointer too. */ export type GoReceiverForm = 'value' | 'pointer'; +/** + * Whether a type satisfies an interface — or whether we could not tell. + * + * `undecided` is the state #2873 was missing. It means a required signature + * named something we could not give an identity to (a package qualifier with no + * recoverable import path), so the comparison was never actually performed. + * Folding it into `unsatisfied` is what let `impact()` answer a confident zero + * for a method that in fact had callers. + * + * It stays distinct from `unsatisfied` in exactly one direction: an undecided + * pair mints NO edge (a speculative one would fan out into fabricated CALLS), + * but it IS reported, so the answer downstream is a lower bound instead of a + * fact. Compare `go/types`, which folds the same case the other way — its + * `hasAllMethods` returns true for an invalid type — because a type checker's + * job is to avoid cascading errors, not to bound a blast radius. + */ +type Verdict = 'satisfied' | 'unsatisfied' | 'undecided'; /** One structural implementor plus the form in which it implements. */ export type GoStructuralImplementor = { readonly structDefId: string; @@ -36,7 +57,12 @@ export type GoStructuralImplementor = { }; type SignatureContext = { readonly packageQualifier: string | undefined; - readonly importQualifiers: ReadonlyMap; + /** Every token this file may write before a `.`, mapped to the package it + * names. Keyed on the token the SOURCE uses, which is the import's local name + * except where that had to be recovered from the path (`…/bar/v2` -> `bar`). + * An `undefined` value is a name that is claimed but has no agreeable + * qualifier; it reads the same as an absent key at the one consumer. */ + readonly importQualifiers: ReadonlyMap; }; type DetectionIndexes = { readonly interfaces: readonly SymbolDefinition[]; @@ -76,7 +102,7 @@ export function detectGoInterfaceImplementations( parsedFiles: readonly ParsedFile[], _indexes: ScopeResolutionIndexes, _model: SemanticModel, -): Map { +): StructuralImplementationResult { return detectGoInterfaceImplementationsFromIndexes(buildDetectionIndexes(parsedFiles, _indexes)); } @@ -457,8 +483,9 @@ function uniqueInterfaceNamed( function detectGoInterfaceImplementationsFromIndexes( indexes: DetectionIndexes, -): Map { +): StructuralImplementationResult { const implementations = new Map(); + const undecided: UndecidedSatisfaction[] = []; const methodSetCache = new Map(); for (const iface of indexes.interfaces) { const required = collectInterfaceMethodSet(iface, indexes, new Set(), methodSetCache); @@ -476,31 +503,60 @@ function detectGoInterfaceImplementationsFromIndexes( // additive: every implementor found before #2855 is still found, in the // same order, and instantiation only ever appends. const formByStructId = new Map(); + const undecidedStructIds = new Set(); for (const candidateSet of [required, ...instantiatedMethodSetsFor(iface, required, indexes)]) { for (const structId of candidateStructIds) { if (formByStructId.get(structId) === 'value') continue; const pointerSet = indexes.effectiveMethodsByStructId.get(structId); if (pointerSet === undefined) continue; // MS(*T) is the superset: if it does not satisfy, neither does MS(T). - if (!methodSetSatisfies(pointerSet, candidateSet, indexes.signatureContextByDefId)) + const verdict = methodSetSatisfies( + pointerSet, + candidateSet, + indexes.signatureContextByDefId, + ); + if (verdict !== 'satisfied') { + // `undecided` mints no edge — a speculative IMPLEMENTS would fan out + // into fabricated CALLS through `emitReceiverBoundCalls`. It is + // recorded instead, so `impact` can report a lower bound rather than + // a confident zero (#2873). A decided `unsatisfied` records nothing: + // that answer is trustworthy. + if (verdict === 'undecided') undecidedStructIds.add(structId); continue; + } // Then ask the narrower question separately — does the VALUE type satisfy? // This is the distinction `var x I = T{}` turns on, and it is a fact about // the program, not a heuristic. const valueSet = indexes.valueMethodsByStructId.get(structId); const satisfiesByValue = valueSet !== undefined && - methodSetSatisfies(valueSet, candidateSet, indexes.signatureContextByDefId); + methodSetSatisfies(valueSet, candidateSet, indexes.signatureContextByDefId) === + 'satisfied'; formByStructId.set(structId, satisfiesByValue ? 'value' : 'pointer'); + undecidedStructIds.delete(structId); } } const implementors: GoStructuralImplementor[] = [...formByStructId].map( ([structDefId, receiverForm]) => ({ structDefId, receiverForm }), ); if (implementors.length > 0) implementations.set(iface.nodeId, implementors); + if (undecidedStructIds.size > 0) { + const candidateNames: string[] = []; + for (const structId of undecidedStructIds) { + const name = indexes.structsById.get(structId)?.qualifiedName; + if (name !== undefined) candidateNames.push(name); + } + undecided.push({ + interfaceDefId: iface.nodeId, + interfaceName: iface.qualifiedName, + filePath: iface.filePath, + undecidedCandidates: undecidedStructIds.size, + candidateNames, + }); + } } - return implementations; + return { implementations, undecided }; } /** @@ -1004,36 +1060,54 @@ function methodSetSatisfies( actual: MethodSet, required: MethodSet, signatureContextByDefId: ReadonlyMap, -): boolean { +): Verdict { + let undecided = false; for (const [name, requiredOverloads] of required) { const actualOverloads = actual.get(name); - if (actualOverloads === undefined) return false; + if (actualOverloads === undefined) return 'unsatisfied'; for (const requiredMethod of requiredOverloads) { // Fast arity pre-filter: if the required method has a known parameter // count, reject immediately when no actual overload matches it. This // avoids the expensive signature normalization loop for obvious mismatches. if (requiredMethod.parameterCount !== undefined) { if (!actualOverloads.some((a) => a.parameterCount === requiredMethod.parameterCount)) { - return false; + return 'unsatisfied'; } } - if (!hasCompatibleMethod(actualOverloads, requiredMethod, signatureContextByDefId)) { - return false; - } + const verdict = compatibleMethodVerdict( + actualOverloads, + requiredMethod, + signatureContextByDefId, + ); + // A decided mismatch anywhere ends it — a type that provably lacks ONE + // required method does not implement the interface, however many other + // methods we could not read. Undecided keeps scanning for exactly that + // reason: a hard no may still be waiting, and it is the better answer. + if (verdict === 'unsatisfied') return 'unsatisfied'; + if (verdict === 'undecided') undecided = true; } } - return true; + return undecided ? 'undecided' : 'satisfied'; } -function hasCompatibleMethod( +function compatibleMethodVerdict( actualOverloads: readonly SymbolDefinition[], requiredMethod: SymbolDefinition, signatureContextByDefId: ReadonlyMap, -): boolean { - if (!hasVerifiableSignature(requiredMethod)) return false; - return actualOverloads.some((actualMethod) => - signaturesCompatible(actualMethod, requiredMethod, signatureContextByDefId), - ); +): Verdict { + // Nothing in the interface's own method to compare against: this is missing + // information, not a difference. It was a `false` before #2873. + if (!hasVerifiableSignature(requiredMethod)) return 'undecided'; + let undecided = false; + for (const actualMethod of actualOverloads) { + const verdict = signaturesCompatible(actualMethod, requiredMethod, signatureContextByDefId); + // One overload that provably matches settles the method — the unknowns on + // the others cannot unsettle it. (Pyright does the same: a resolvable path + // suppresses the partially-unknown diagnostic from the others.) + if (verdict === 'satisfied') return 'satisfied'; + if (verdict === 'undecided') undecided = true; + } + return undecided ? 'undecided' : 'unsatisfied'; } function methodSetHasVerifiableSignatures(methods: MethodSet): boolean { @@ -1056,61 +1130,115 @@ function signaturesCompatible( actual: SymbolDefinition, required: SymbolDefinition, signatureContextByDefId: ReadonlyMap, -): boolean { +): Verdict { const actualContext = signatureContextByDefId.get(actual.nodeId); const requiredContext = signatureContextByDefId.get(required.nodeId); - return ( - countsCompatible(actual.parameterCount, required.parameterCount) && - countsCompatible(actual.requiredParameterCount, required.requiredParameterCount) && - parameterTypesCompatible( - actual.parameterTypes, - required.parameterTypes, - actualContext, - requiredContext, - ) && - returnTypesCompatible(actual.returnType, required.returnType, actualContext, requiredContext) + if ( + !countsCompatible(actual.parameterCount, required.parameterCount) || + !countsCompatible(actual.requiredParameterCount, required.requiredParameterCount) + ) { + return 'unsatisfied'; + } + // A decided mismatch beats an unknown — it is the answer we can stand behind — + // so the parameter verdict only short-circuits when it is `unsatisfied`. + const parameters = parameterTypesVerdict(actual, required, actualContext, requiredContext); + if (parameters === 'unsatisfied') return 'unsatisfied'; + const returns = returnTypeVerdict( + actual.returnType, + required.returnType, + actualContext, + requiredContext, ); + if (returns === 'unsatisfied') return 'unsatisfied'; + return parameters === 'undecided' || returns === 'undecided' ? 'undecided' : 'satisfied'; } function countsCompatible(actual: number | undefined, required: number | undefined): boolean { return actual === undefined || required === undefined || actual === required; } -function parameterTypesCompatible( - actual: readonly string[] | undefined, - required: readonly string[] | undefined, +function parameterTypesVerdict( + actualDef: SymbolDefinition, + requiredDef: SymbolDefinition, actualContext: SignatureContext | undefined, requiredContext: SignatureContext | undefined, -): boolean { - if (actual === undefined || required === undefined) return true; - if (actual.length !== required.length) return false; - return actual.every((type, index) => { - const actualType = normalizeSignatureType(type, actualContext); - const requiredType = normalizeSignatureType(required[index]!, requiredContext); - return actualType !== undefined && requiredType !== undefined && actualType === requiredType; - }); +): Verdict { + const actual = actualDef.parameterTypes; + const required = requiredDef.parameterTypes; + if (actual === undefined || required === undefined) { + // A method that takes nothing has no list to carry — that is a decided + // agreement, not a gap, and it is the shape of every `Close() error`. + if (actualDef.parameterCount === 0 && requiredDef.parameterCount === 0) return 'satisfied'; + // Otherwise the types really are unread. This is where the old code assumed + // `true` and called two signatures compatible without comparing them. + return 'undecided'; + } + if (actual.length !== required.length) return 'unsatisfied'; + // Indexed loop, not `entries()`: this is the innermost comparison in the + // detection pass and runs once per parameter per candidate pair. + let undecided = false; + for (let index = 0; index < actual.length; index++) { + const verdict = typeVerdict(actual[index]!, required[index]!, actualContext, requiredContext); + if (verdict === 'unsatisfied') return 'unsatisfied'; + if (verdict === 'undecided') undecided = true; + } + return undecided ? 'undecided' : 'satisfied'; } -function returnTypesCompatible( +function returnTypeVerdict( actual: string | undefined, required: string | undefined, actualContext: SignatureContext | undefined, requiredContext: SignatureContext | undefined, -): boolean { - if (required === undefined) return actual === undefined; - if (actual === undefined) return false; +): Verdict { + if (required === undefined) return actual === undefined ? 'satisfied' : 'unsatisfied'; + if (actual === undefined) return 'unsatisfied'; + return typeVerdict(actual, required, actualContext, requiredContext); +} + +/** The one place a type spelling decides anything, and the only mint site of + * `undecided` below the method level: `normalizeSignatureType` returns + * `undefined` when a package qualifier has no identity we could recover, and + * two spellings we could not normalize are not thereby different. */ +function typeVerdict( + actual: string, + required: string, + actualContext: SignatureContext | undefined, + requiredContext: SignatureContext | undefined, +): Verdict { const actualType = normalizeSignatureType(actual, actualContext); const requiredType = normalizeSignatureType(required, requiredContext); - return actualType !== undefined && requiredType !== undefined && actualType === requiredType; + if (actualType === undefined || requiredType === undefined) return 'undecided'; + return actualType === requiredType ? 'satisfied' : 'unsatisfied'; } +/** Normalized form of every type spelling seen in a file, keyed by its context. + * + * Normalization is a pure function of (spelling, context), and a context is + * immutable once built — so this is a cache, not state. It earns its keep + * because #2873 removed the early bail: a parameter list that named an + * out-of-repo type used to normalize to `undefined` and stop the comparison at + * parameter 0, and now every pair of (interface method, candidate struct) walks + * its whole signature, re-normalizing both sides once per candidate. */ +const normalizedTypesByContext = new WeakMap>(); + function normalizeSignatureType(typeName: string, context?: SignatureContext): string | undefined { // Go type identity includes pointer/slice/map/variadic shape and package // qualifiers. Only erase whitespace and qualify bare local type names; stripping // `*`, `[]`, `...`, or `pkg.` would make non-identical signatures compare equal. const compact = typeName.replace(/\s+/g, ''); if (context === undefined) return compact; - return qualifyGoSignatureTypes(compact, context); + let normalized = normalizedTypesByContext.get(context); + if (normalized === undefined) { + normalized = new Map(); + normalizedTypesByContext.set(context, normalized); + } + // `has`, not a truthiness check: `undefined` — "no agreeable identity" — is + // itself a result worth caching, and it is the one this file mints most. + if (normalized.has(compact)) return normalized.get(compact); + const qualified = qualifyGoSignatureTypes(compact, context); + normalized.set(compact, qualified); + return qualified; } function qualifyGoSignatureTypes(typeName: string, context: SignatureContext): string | undefined { @@ -1138,12 +1266,30 @@ function signatureContextForFile( parsed: ParsedFile, indexes: ScopeResolutionIndexes, ): SignatureContext { - const importQualifiers = new Map(); + // A key present with an `undefined` value means "in-repo, but no directory to + // name it by" — a repo-ROOT package, whose own file spells its types bare, so + // no qualifier either side can agree on exists. It still has to occupy the + // name, or the fallback below would label a repo package external. + const importQualifiers = new Map(); const importEdges = indexes.imports?.get(parsed.moduleScope) ?? []; for (const edge of importEdges) { if (edge.kind !== 'namespace' || edge.targetFile === null) continue; - const qualifier = packageQualifierForFile(edge.targetFile); - if (qualifier !== undefined) importQualifiers.set(edge.localName, qualifier); + importQualifiers.set(edge.localName, packageQualifierForFile(edge.targetFile)); + } + // An import that resolves to no file in the repository — every stdlib and + // third-party package — still has an identity: its import path, which the + // parsed directive kept even though the finalized `ImportEdge` did not (#2873). + // Without this fallback `ctx context.Context` normalized to `undefined`, and + // `undefined` reads as "signatures differ" on both sides at once, so two + // textually identical methods compared unequal and Go interface satisfaction + // only ever succeeded for builtin-only signatures. + for (const directive of parsed.parsedImports) { + if (directive.kind !== 'namespace') continue; + const token = goImportToken(directive.localName, directive.targetRaw); + // The edges ran first, so a token an in-repo import already claimed keeps + // its package directory — the fallback fills gaps, it does not compete. + if (importQualifiers.has(token)) continue; + importQualifiers.set(token, externalPackageQualifier(directive.targetRaw)); } return { packageQualifier: packageQualifierForFile(parsed.filePath), @@ -1151,6 +1297,39 @@ function signatureContextForFile( }; } +/** Identity for a package that lives outside the repository. + * + * The import path is the exact identity — `net/http` and `example.com/x/http` + * are different packages that both spell their qualifier `http`, so keying on + * the local name would make them compare equal. The prefix keeps the result in + * a namespace no in-repo qualifier can reach: no package directory can begin + * with the literal `extern:`. */ +function externalPackageQualifier(importPath: string): string { + return `extern:${importPath}`; +} + +/** The token Go source writes before the `.` for this import. + * + * An alias names its own token, so it is returned as-is. An unaliased import + * arrives here spelled as the last path segment, which is right until a module + * carries a major version: `github.com/foo/bar/v2` is written `bar` and + * `gopkg.in/yaml.v3` is written `yaml`, per the rule the go tool applies. + * + * Deriving "aliased" from the path rather than from `importedName` is + * deliberate — the Go extractor sets both names to the alias when there is one + * (`import-decomposer.ts`), so the two fields never disagree. + * + * A package whose name diverges from its path for any OTHER reason cannot be + * recovered without reading the dependency's own source, which is by definition + * outside the repository. Those stay unresolved, which is the safe direction. */ +function goImportToken(localName: string, importPath: string): string { + const segments = importPath.split('/').filter((segment) => segment.length > 0); + const leaf = segments.pop() ?? importPath; + if (localName !== leaf) return localName; + const name = /^v\d+$/.test(leaf) ? (segments.pop() ?? leaf) : leaf; + return name.replace(/\.v\d+$/, ''); +} + /** The package directory, or `undefined` for a repo-root file. * * Shares `goPackageDir` with the package-clause resolver rather than repeating diff --git a/gitnexus/src/core/ingestion/languages/java.ts b/gitnexus/src/core/ingestion/languages/java.ts index 047a87791..45386dd7b 100644 --- a/gitnexus/src/core/ingestion/languages/java.ts +++ b/gitnexus/src/core/ingestion/languages/java.ts @@ -15,6 +15,12 @@ import type { AstFrameworkPatternConfig } from '../language-provider.js'; import { createLeadingDocDescriptionExtractor } from '../utils/ast-helpers.js'; import { javaTypeConfig } from '../type-extractors/jvm.js'; import { extractSpringRoutes, extractSpringTypes } from '../route-extractors/spring.js'; +import { + extractJavaModuleConstants, + foldJavaOperands, + isJavaConstantFile, + prepareJavaRouteConstants, +} from '../route-extractors/java-const-resolver.js'; import { javaExportChecker } from '../export-detection.js'; import { createImportResolver } from '../import-resolvers/resolver-factory.js'; import { javaImportConfig } from '../import-resolvers/configs/jvm.js'; @@ -23,14 +29,21 @@ import { createCallExtractor } from '../call-extractors/generic.js'; import { javaCallConfig } from '../call-extractors/configs/jvm.js'; import { createFieldExtractor } from '../field-extractors/generic.js'; import { javaConfig } from '../field-extractors/configs/jvm.js'; -import { createMethodExtractor } from '../method-extractors/generic.js'; -import { javaMethodConfig } from '../method-extractors/configs/jvm.js'; import { createVariableExtractor } from '../variable-extractors/generic.js'; import { javaVariableConfig } from '../variable-extractors/configs/jvm.js'; import { createJavaCfgVisitor } from '../cfg/visitors/java.js'; import { assertCloneable } from '../workers/clone-safety.js'; -import { collectJavaCaptureSideChannel } from './java/capture-side-channel.js'; +import { + collectJavaCaptureSideChannel, + getJavaSpringMessageProducerFacts, + getJavaSpringNonHttpHandlerFacts, +} from './java/capture-side-channel.js'; import type { SymbolDefinition } from 'gitnexus-shared'; +import { + javaRecordMethodExtractor, + shouldSkipJavaRecordComponentDefinition, +} from './java/record-components.js'; +import { synthesizeLombokAccessors } from './java/lombok-synthesizer.js'; import { emitJavaScopeCaptures, interpretJavaImport, @@ -42,6 +55,7 @@ import { javaArityCompatibility, resolveJavaImportTarget, } from './java/index.js'; +import { javaRuntimeSymbolStrategy } from './java/spring-actuator.js'; /** * Java names the platform owns, matched against a BARE IDENTIFIER — a dropped @@ -186,9 +200,11 @@ export const javaProvider = defineLanguage({ mroStrategy: 'implements-split', callExtractor: createCallExtractor(javaCallConfig), fieldExtractor: createFieldExtractor(javaConfig), - methodExtractor: createMethodExtractor(javaMethodConfig), + methodExtractor: javaRecordMethodExtractor, + shouldSkipDefinitionCapture: shouldSkipJavaRecordComponentDefinition, variableExtractor: createVariableExtractor(javaVariableConfig), classExtractor: createClassExtractor(javaClassConfig), + runtimeSymbolStrategy: javaRuntimeSymbolStrategy, // ── Javadoc → description (issue #2270) ── descriptionExtractor: createLeadingDocDescriptionExtractor(), @@ -213,4 +229,34 @@ export const javaProvider = defineLanguage({ // ── Route extraction ── extractDecoratorRoutes: extractSpringRoutes, extractRouteInheritanceTypes: extractSpringTypes, + + synthesizeStructureMembers: synthesizeLombokAccessors, + + // ── #2980: constant harvest + qualified-ref fold for non-literal mapping + // paths (`@PostMapping(ApiPaths.SAVE_V1)`) — kept behind provider hooks so + // the shared ingestion layers stay language-agnostic. The heuristic is + // SYNTAX-driven (field/import shape), never a class-name pattern: constant + // classes are routinely named `ApiPaths`/`Routes`/`Paths`, which a + // `*Constants`-style gate would silently drop (review round-2 High finding). + extractModuleConstants: extractJavaModuleConstants, + // One gate, shared with the group side's `prepareRepo` pre-pass so the two + // subsystems cannot disagree about which files define constants (see + // JAVA_CONSTANT_FILE_RE — the previous divergence dropped constant + // INTERFACES on this side only, which cost the graph its Route nodes while + // the group still published the contract). + moduleConstantHeuristic: (content) => + isJavaConstantFile(content) || + // Class imports and static (including on-demand) imports can bind a + // constant ref. Ordinary `import a.b.*;` is not a Java type import and is + // not expanded by extractJavaModuleConstants, so it must not harvest. + /\bimport\s+(?:static\s+[\w.]+(?:\.\*)?|[\w.]+)\s*;/.test(content), + prepareRouteConstants: prepareJavaRouteConstants, + foldRoutePathOperands: foldJavaOperands, + // Async messaging facts for the `springDestinations` phase. Both stores are + // repopulated on the main thread by `applyJavaCaptureSideChannel`, so this + // answers for cache hits and misses alike. + getSpringMessagingFacts: (filePath) => ({ + handlers: getJavaSpringNonHttpHandlerFacts(filePath), + producers: getJavaSpringMessageProducerFacts(filePath), + }), }); diff --git a/gitnexus/src/core/ingestion/languages/java/analysis-features.ts b/gitnexus/src/core/ingestion/languages/java/analysis-features.ts index b85616602..64ff16294 100644 --- a/gitnexus/src/core/ingestion/languages/java/analysis-features.ts +++ b/gitnexus/src/core/ingestion/languages/java/analysis-features.ts @@ -5,15 +5,36 @@ function isSpringApplicationConfig(filePath: string): boolean { return /^application(?:-[^.]+)?\.(?:properties|ya?ml)$/i.test(base); } -/** Durable completeness contract for Java Spring configuration bindings. */ +/** Durable completeness contract for Java and Kotlin Spring configuration bindings. */ export const SPRING_CONFIG_BINDINGS_FEATURE: AnalysisFeatureDescriptor = { id: 'spring.config-bindings', - version: 1, - // Java sources need consumer extraction even without config files (missing - // placeholders still get unresolved markers). Config-only repositories also - // need a one-time rebuild to backfill language-agnostic Property nodes. + version: 2, + // Java and Kotlin sources need consumer extraction even without config files + // (missing placeholders still get unresolved markers). Config-only + // repositories also need a one-time rebuild to backfill language-agnostic + // Property nodes. Gradle Kotlin DSL is not a consumer source. appliesTo: (filePaths) => - filePaths.some( - (filePath) => filePath.toLowerCase().endsWith('.java') || isSpringApplicationConfig(filePath), - ), + filePaths.some((filePath) => { + const normalized = filePath.replaceAll('\\', '/').toLowerCase(); + if (normalized.endsWith('.gradle.kts')) return false; + return ( + normalized.endsWith('.java') || + normalized.endsWith('.kt') || + isSpringApplicationConfig(filePath) + ); + }), +}; + +/** Durable completeness contract for implicit Java record-component accessors. */ +export const JAVA_RECORD_COMPONENT_ACCESSORS_FEATURE: AnalysisFeatureDescriptor = { + id: 'java.record-component-accessors', + version: 1, + appliesTo: (filePaths) => filePaths.some((filePath) => filePath.toLowerCase().endsWith('.java')), +}; + +/** Durable completeness contract for Java heritage captures. */ +export const JAVA_ENUM_INTERFACE_HERITAGE_FEATURE: AnalysisFeatureDescriptor = { + id: 'java.heritage-captures', + version: 1, + appliesTo: (filePaths) => filePaths.some((filePath) => filePath.toLowerCase().endsWith('.java')), }; diff --git a/gitnexus/src/core/ingestion/languages/java/capture-side-channel.ts b/gitnexus/src/core/ingestion/languages/java/capture-side-channel.ts index 7a7f82685..a8c5e268e 100644 --- a/gitnexus/src/core/ingestion/languages/java/capture-side-channel.ts +++ b/gitnexus/src/core/ingestion/languages/java/capture-side-channel.ts @@ -13,6 +13,9 @@ import type { JavaSpringConfigConsumerFact } from './spring-config-bindings.js'; import type { JavaSpringAopFact } from './spring-aop.js'; import type { JavaSpringConditionalFact } from './spring-conditionals.js'; import type { JavaSpringDiClassFact } from './spring-di.js'; +import type { SpringDynamicLookupFact } from '../../frameworks/spring/dynamic-lookups.js'; +import type { SpringMessageProducerFact } from '../../frameworks/spring/message-producers.js'; +import type { JavaSpringNonHttpHandlerFact } from './spring-non-http-handlers.js'; export type JavaClassAnnotationFact = ClassAnnotationFact; @@ -24,6 +27,9 @@ export interface JavaCaptureSideChannel { readonly springConfigConsumers?: readonly JavaSpringConfigConsumerFact[]; readonly springConditionalFacts?: readonly JavaSpringConditionalFact[]; readonly springDiFacts?: readonly JavaSpringDiClassFact[]; + readonly springDynamicLookupFacts?: readonly SpringDynamicLookupFact[]; + readonly springNonHttpHandlerFacts?: readonly JavaSpringNonHttpHandlerFact[]; + readonly springMessageProducerFacts?: readonly SpringMessageProducerFact[]; } const classAnnotations = createClassAnnotationFactStore(); @@ -31,6 +37,9 @@ const springAopFacts = new Map(); const springConfigConsumers = new Map(); const springConditionalFacts = new Map(); const springDiFacts = new Map(); +const springDynamicLookupFacts = new Map(); +const springNonHttpHandlerFacts = new Map(); +const springMessageProducerFacts = new Map(); /** Clear facts retained by a prior workspace pass in a long-lived process. */ export function clearJavaClassAnnotationFacts(): void { @@ -39,6 +48,9 @@ export function clearJavaClassAnnotationFacts(): void { springConfigConsumers.clear(); springConditionalFacts.clear(); springDiFacts.clear(); + springDynamicLookupFacts.clear(); + springNonHttpHandlerFacts.clear(); + springMessageProducerFacts.clear(); } export function setJavaSpringAopFacts(filePath: string, facts: readonly JavaSpringAopFact[]): void { @@ -98,6 +110,48 @@ export function getJavaSpringDiFacts(filePath: string): readonly JavaSpringDiCla return springDiFacts.get(filePath) ?? []; } +export function setJavaSpringDynamicLookupFacts( + filePath: string, + facts: readonly SpringDynamicLookupFact[], +): void { + if (facts.length === 0) springDynamicLookupFacts.delete(filePath); + else springDynamicLookupFacts.set(filePath, facts); +} + +export function getJavaSpringDynamicLookupFacts( + filePath: string, +): readonly SpringDynamicLookupFact[] { + return springDynamicLookupFacts.get(filePath) ?? []; +} + +export function setJavaSpringNonHttpHandlerFacts( + filePath: string, + facts: readonly JavaSpringNonHttpHandlerFact[], +): void { + if (facts.length === 0) springNonHttpHandlerFacts.delete(filePath); + else springNonHttpHandlerFacts.set(filePath, facts); +} + +export function getJavaSpringNonHttpHandlerFacts( + filePath: string, +): readonly JavaSpringNonHttpHandlerFact[] { + return springNonHttpHandlerFacts.get(filePath) ?? []; +} + +export function setJavaSpringMessageProducerFacts( + filePath: string, + facts: readonly SpringMessageProducerFact[], +): void { + if (facts.length === 0) springMessageProducerFacts.delete(filePath); + else springMessageProducerFacts.set(filePath, facts); +} + +export function getJavaSpringMessageProducerFacts( + filePath: string, +): readonly SpringMessageProducerFact[] { + return springMessageProducerFacts.get(filePath) ?? []; +} + /** Snapshot worker-local Java annotation facts for ParsedFile serialization. */ export function collectJavaCaptureSideChannel( filePath: string, @@ -107,6 +161,9 @@ export function collectJavaCaptureSideChannel( const configConsumers = springConfigConsumers.get(filePath) ?? []; const conditionFacts = springConditionalFacts.get(filePath) ?? []; const diFacts = springDiFacts.get(filePath) ?? []; + const dynamicLookupFacts = springDynamicLookupFacts.get(filePath) ?? []; + const nonHttpHandlerFacts = springNonHttpHandlerFacts.get(filePath) ?? []; + const messageProducerFacts = springMessageProducerFacts.get(filePath) ?? []; const packageFact = getJavaPackageFact(filePath); if ( facts.length === 0 && @@ -114,6 +171,9 @@ export function collectJavaCaptureSideChannel( configConsumers.length === 0 && conditionFacts.length === 0 && diFacts.length === 0 && + dynamicLookupFacts.length === 0 && + nonHttpHandlerFacts.length === 0 && + messageProducerFacts.length === 0 && packageFact === undefined ) { return undefined; @@ -126,6 +186,11 @@ export function collectJavaCaptureSideChannel( ...(configConsumers.length > 0 ? { springConfigConsumers: configConsumers } : {}), ...(conditionFacts.length > 0 ? { springConditionalFacts: conditionFacts } : {}), ...(diFacts.length > 0 ? { springDiFacts: diFacts } : {}), + ...(dynamicLookupFacts.length > 0 ? { springDynamicLookupFacts: dynamicLookupFacts } : {}), + ...(nonHttpHandlerFacts.length > 0 ? { springNonHttpHandlerFacts: nonHttpHandlerFacts } : {}), + ...(messageProducerFacts.length > 0 + ? { springMessageProducerFacts: messageProducerFacts } + : {}), }; } @@ -148,6 +213,9 @@ export function applyJavaCaptureSideChannel(parsed: ParsedFile): void { setJavaSpringConfigConsumerFacts(parsed.filePath, []); setJavaSpringConditionalFacts(parsed.filePath, []); setJavaSpringDiFacts(parsed.filePath, []); + setJavaSpringDynamicLookupFacts(parsed.filePath, []); + setJavaSpringNonHttpHandlerFacts(parsed.filePath, []); + setJavaSpringMessageProducerFacts(parsed.filePath, []); setJavaPackageFact(parsed.filePath, UNKNOWN_JVM_PACKAGE_FACT); return; } @@ -168,6 +236,18 @@ export function applyJavaCaptureSideChannel(parsed: ParsedFile): void { parsed.filePath, Array.isArray(data.springDiFacts) ? data.springDiFacts : [], ); + setJavaSpringDynamicLookupFacts( + parsed.filePath, + Array.isArray(data.springDynamicLookupFacts) ? data.springDynamicLookupFacts : [], + ); + setJavaSpringNonHttpHandlerFacts( + parsed.filePath, + Array.isArray(data.springNonHttpHandlerFacts) ? data.springNonHttpHandlerFacts : [], + ); + setJavaSpringMessageProducerFacts( + parsed.filePath, + Array.isArray(data.springMessageProducerFacts) ? data.springMessageProducerFacts : [], + ); setJavaPackageFact( parsed.filePath, isJvmPackageFact(data.packageFact) ? data.packageFact : UNKNOWN_JVM_PACKAGE_FACT, diff --git a/gitnexus/src/core/ingestion/languages/java/captures.ts b/gitnexus/src/core/ingestion/languages/java/captures.ts index 60d315df5..5d31aed4a 100644 --- a/gitnexus/src/core/ingestion/languages/java/captures.ts +++ b/gitnexus/src/core/ingestion/languages/java/captures.ts @@ -39,17 +39,30 @@ import { setJavaSpringConfigConsumerFacts, setJavaSpringConditionalFacts, setJavaSpringDiFacts, + setJavaSpringDynamicLookupFacts, + setJavaSpringMessageProducerFacts, + setJavaSpringNonHttpHandlerFacts, } from './capture-side-channel.js'; import { captureJavaPackageFact } from './package-facts.js'; import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js'; import { captureJavaSpringConfigConsumerFacts } from './spring-config-bindings.js'; import { captureJavaSpringDiClassFact, type JavaSpringDiClassFact } from './spring-di.js'; +import type { SpringDynamicLookupFact } from '../../frameworks/spring/dynamic-lookups.js'; +import { captureJavaSpringDynamicLookupFact } from './spring-dynamic-lookup.js'; +import type { SpringMessageProducerFact } from '../../frameworks/spring/message-producers.js'; +import { captureJavaSpringMessageProducerFact } from './spring-message-producers.js'; import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js'; import { captureJavaSpringAopFacts, type JavaSpringAopFact } from './spring-aop.js'; import { captureJavaSpringConditionalFacts, type JavaSpringConditionalFact, } from './spring-conditionals.js'; +import { + captureJavaSpringNonHttpHandlerFacts, + type JavaSpringNonHttpHandlerFact, +} from './spring-non-http-handlers.js'; +import { synthesizeJavaRecordComponentAccessorCaptures } from './record-components.js'; +import { synthesizeLombokAccessorCaptures } from './lombok-synthesizer.js'; /** Declaration anchors that carry function-like arity metadata. */ const FUNCTION_DECL_TAGS = ['@declaration.method', '@declaration.constructor'] as const; @@ -138,7 +151,11 @@ export function emitJavaScopeCaptures( const springAopTypeNodeIds = new Set(); const springConditionalFacts: JavaSpringConditionalFact[] = []; const springDiFacts: JavaSpringDiClassFact[] = []; + const springNonHttpHandlerFacts: JavaSpringNonHttpHandlerFact[] = []; const springDiClassNodeIds = new Set(); + const springDynamicLookupFacts: SpringDynamicLookupFact[] = []; + const springMessageProducerFacts: SpringMessageProducerFact[] = []; + const springMemberCallNodeIds = new Set(); for (const m of rawMatches) { const grouped: Record = {}; @@ -158,6 +175,17 @@ export function emitJavaScopeCaptures( } if (Object.keys(grouped).length === 0) continue; + // One visit per member call node: the same invocation can back several + // query matches, and both Spring call-shape captures must see it once. + const memberCallNode = nodeIfType(nodeMap['@reference.call.member'], 'method_invocation'); + if (memberCallNode !== null && !springMemberCallNodeIds.has(memberCallNode.id)) { + springMemberCallNodeIds.add(memberCallNode.id); + const lookupFact = captureJavaSpringDynamicLookupFact(memberCallNode, filePath); + if (lookupFact !== null) springDynamicLookupFacts.push(lookupFact); + const producerFact = captureJavaSpringMessageProducerFact(memberCallNode, filePath); + if (producerFact !== null) springMessageProducerFacts.push(producerFact); + } + const springAopTypeNode = [ nodeIfType(nodeMap['@scope.class'], 'class_declaration'), nodeIfType(nodeMap['@scope.class'], 'interface_declaration'), @@ -173,6 +201,9 @@ export function emitJavaScopeCaptures( springConditionalFacts.push( ...captureJavaSpringConditionalFacts(springDiClassNode, filePath), ); + springNonHttpHandlerFacts.push( + ...captureJavaSpringNonHttpHandlerFacts(springDiClassNode, filePath), + ); const fact = captureJavaSpringDiClassFact(springDiClassNode, filePath); if (fact !== null) springDiFacts.push(fact); } @@ -391,12 +422,17 @@ export function emitJavaScopeCaptures( setJavaSpringAopFacts(filePath, springAopFacts); setJavaSpringConditionalFacts(filePath, springConditionalFacts); setJavaSpringDiFacts(filePath, springDiFacts); + setJavaSpringDynamicLookupFacts(filePath, springDynamicLookupFacts); + setJavaSpringNonHttpHandlerFacts(filePath, springNonHttpHandlerFacts); + setJavaSpringMessageProducerFacts(filePath, springMessageProducerFacts); return [ ...resolveVarTypeBindings(out), ...synthesizeJavaInheritanceReferences(tree.rootNode), ...synthesizeJavaExplicitConstructorReferences(tree.rootNode), ...synthesizeJavaAnonymousClassDeclarations(tree.rootNode), + ...synthesizeJavaRecordComponentAccessorCaptures(tree.rootNode), + ...synthesizeLombokAccessorCaptures(tree.rootNode), ...synthesizeCallableFlowCaptures(tree.rootNode, JAVA_CALLABLE_CAPTURE_OPTIONS), ]; } @@ -424,6 +460,7 @@ function synthesizeJavaAnonymousClassDeclarations(rootNode: SyntaxNode): Capture out.push({ '@declaration.class': nodeToCapture('@declaration.class', body), '@declaration.name': syntheticCapture('@declaration.name', body, identity.name), + '@declaration.is-synthetic': syntheticCapture('@declaration.is-synthetic', body, 'true'), }); // Inheritance: the anonymous class extends/implements its constructed @@ -485,6 +522,11 @@ function synthesizeJavaAnonymousClassDeclarations(rootNode: SyntaxNode): Capture out.push({ '@declaration.class': nodeToCapture('@declaration.class', bodyNode), '@declaration.name': syntheticCapture('@declaration.name', bodyNode, bodiedIdentity.name), + '@declaration.is-synthetic': syntheticCapture( + '@declaration.is-synthetic', + bodyNode, + 'true', + ), }); if (hostEnum !== undefined) { out.push({ @@ -631,40 +673,29 @@ function findEnclosingTypeDeclaration(node: SyntaxNode): SyntaxNode | null { } /** - * Synthesize `@reference.inherits` captures from Java class heritage so the - * registry-primary scope-resolution path emits EXTENDS / IMPLEMENTS edges - * (mirrors C++ `emitCppInheritanceCaptures`). Without this, Java inheritance - * edges came only from the legacy heritage-capture leg (removed in #942), which - * is dropped for registry-primary languages in the worker pipeline (issue #1951). + * Synthesize `@reference.inherits` captures from Java type heritage for the + * authoritative registry-primary EXTENDS / IMPLEMENTS pre-pass (mirrors C++ + * `emitCppInheritanceCaptures`). * * Scope covers `class_declaration` (`superclass` extends + `interfaces` - * implements clauses) AND `interface_declaration` (`extends_interfaces` → - * interface-to-interface EXTENDS), matching the legacy Java heritage query - * (tree-sitter-queries.ts), which has a dedicated `interface_declaration - * (extends_interfaces (type_list …))` arm. Without the interface arm the - * registry-primary synth silently dropped every `interface IA extends IB` - * edge while the legacy leg emitted it — the exact =0/=N parity break #1951 - * targets. Enum/record heritage stays unemitted (no legacy arm). Generic - * bases (`extends Box`, `implements IFoo`) ARE emitted here: the legacy - * heritage query was widened to capture the inner `type_identifier` of a - * `generic_type` (tree-sitter-queries.ts), so both paths now agree on SIMPLE - * (unqualified) generic bases — the more-correct behavior, consistent with - * C#/Rust (#1951). Qualified bases (`a.b.Base`, `a.b.Box`, `a.b.IFoo`) are - * ALSO now at parity (#1956 tri-review U2): the synth resolves them by their - * `scoped_type_identifier` tail, and the legacy heritage query was widened - * with matching `scoped_type_identifier` arms (plain + generic-wrapped). The + * implements clauses), `record_declaration` and `enum_declaration` + * (`interfaces` implements clauses), and `interface_declaration` + * (`extends_interfaces` clauses). Interface + * inheritance was restored for registry-primary resolution in #1951. Record + * graph nodes became canonical link targets in #2801 / PR #2871, so their + * `implements` clauses must participate for interface dispatch (#2900). + * Enums use the same tree-sitter `interfaces` field and participate as + * class-like `Enum` graph nodes (#2918). + * + * Generic bases (`extends Box`, `implements IFoo`) and qualified bases + * (`a.b.Base`, `a.b.Box`, `a.b.IFoo`) are normalized to their simple + * lookup-name tails, consistent with C#/Rust and the V1 binding contract. The * EXTENDS-vs-IMPLEMENTS split is decided downstream from the resolved target's * symbol kind (`preEmitInheritanceEdges`): a superclass resolves to a class * (EXTENDS), an implemented interface resolves to an interface (IMPLEMENTS). * An `interface IA extends IB` base resolves to an Interface too, so it is - * emitted as IMPLEMENTS — matching the legacy `interface_declaration` arm, - * which tagged the bases as implements (`kind: 'implements'`) and likewise - * resolves them as interfaces. The synth therefore does not need to know the - * declaration's own kind; it only emits inherits sites and lets the resolved - * target decide the edge type. - * Base names are normalized to their bare simple identifier (`Box` → `Box`, - * `java.io.Serializable` → `Serializable`) to match the V1 simple-name - * `findClassBindingInScope` contract. + * emitted as IMPLEMENTS. The synth only emits inheritance sites and lets the + * resolved target decide the edge type. */ function synthesizeJavaInheritanceReferences(root: SyntaxNode): CaptureMatch[] { const out: CaptureMatch[] = []; @@ -676,6 +707,14 @@ function synthesizeJavaInheritanceReferences(root: SyntaxNode): CaptureMatch[] { if (superclass !== null) { for (const base of superclass.namedChildren) emitJavaInheritanceBase(out, base); } + } + if ( + node.type === 'class_declaration' || + node.type === 'record_declaration' || + node.type === 'enum_declaration' + ) { + // Records and enums cannot declare a superclass; all three declarations + // expose implemented interfaces through the same tree-sitter field. const interfaces = node.childForFieldName('interfaces'); if (interfaces !== null) { for (const typeList of interfaces.namedChildren) { @@ -733,15 +772,22 @@ function javaBaseSimpleNameOf(typeNode: SyntaxNode): string | undefined { function javaBaseLookupNameNode(node: SyntaxNode): SyntaxNode | null { switch (node.type) { case 'type_identifier': - return node; - case 'scoped_type_identifier': + return node.isMissing || node.text.length === 0 ? null : node; + case 'scoped_type_identifier': { // `java.io.Serializable` → trailing `type_identifier` (`Serializable`). - return node.lastNamedChild; + const tail = node.lastNamedChild; + return tail === null ? null : javaBaseLookupNameNode(tail); + } case 'generic_type': { // `Box` → recurse into the base type (`Box`). const first = node.firstNamedChild; return first === null ? null : javaBaseLookupNameNode(first); } + case 'annotated_type': { + // The final named child is the base type; preceding children are annotations. + const type = node.lastNamedChild; + return type === null ? null : javaBaseLookupNameNode(type); + } default: return null; } diff --git a/gitnexus/src/core/ingestion/languages/java/import-target.ts b/gitnexus/src/core/ingestion/languages/java/import-target.ts index b78b6369b..4843e854d 100644 --- a/gitnexus/src/core/ingestion/languages/java/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/java/import-target.ts @@ -1,26 +1,104 @@ /** - * Adapter from `(ParsedImport, WorkspaceIndex)` → concrete file path. + * Adapter from `(ParsedImport, WorkspaceIndex)` → the file(s) an import names. * - * Converts Java package paths (dots → slashes) and tries: - * 1. Exact file match: `com/example/User.java` - * 2. Suffix match for nested layouts - * 3. Directory match (wildcard imports) - * 4. Progressive prefix stripping for non-standard layouts + * Delegates to `module-resolution.ts`, which resolves a Java import the way + * Java defines it: a fully-qualified type name looked up against the packages + * the workspace's files DECLARE. * - * Returns `null` for unresolvable / JDK imports. + * ## What #2953 replaced, and why path shape could not work + * + * This resolver used to turn dots into slashes and hunt for a file whose path + * ended that way — exact whole path, then any segment-suffix, then the first + * `.java` directly inside a matching directory — retrying the whole cascade + * with each leading segment stripped. Four legs, all describing where a file + * SITS rather than what it DECLARES. + * + * Path shape is a convention, so it mostly worked, and failed hardest on the + * case that matters: it had no way to tell an import of something outside the + * repository from one inside it. `java.util.List` became `util/List`, then + * `List`, and bound to any `List.java` anywhere in the tree — a fabricated + * IMPORTS edge at full confidence, for an import naming a JDK class. Every JDK + * and third-party import in a repository was a candidate. + * + * Two secondary defects went with it, both consequences of resolving by shape: + * + * - a wildcard `import com.example.*;` answered with ONE arbitrary file — the + * first `.java` in the package directory in `allFilePaths` iteration order, + * which the previous header documented at length as being decided by "a + * property of the file list, not of the import". It now answers with every + * file declaring that package, which is what the import actually names. + * - a file's location and its package were assumed to agree. They need not: + * `weird/path/User.java` declaring `package com.example;` is importable as + * `com.example.User`, and `com/example/User.java` declaring nothing is in + * the default package and importable as nothing at all. Both now resolve + * correctly, because the declaration is what is read. + * + * The package declaration was already being extracted during the parse pass and + * has been reachable here through `getJavaPackageFact` the whole time; nothing + * read it. So this costs no new I/O — no `pom.xml`, no `build.gradle`, no + * source-root inference. The workspace describes itself. */ -import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared'; +import type { ParsedFile, ParsedImport, WorkspaceIndex } from 'gitnexus-shared'; +import { perFileSet } from '../../import-resolvers/per-file-set.js'; +import { getJavaPackageFact } from './package-facts.js'; +import { + buildJavaPackageIndex, + resolveJavaModule, + type JavaPackageIndex, +} from './module-resolution.js'; export interface JavaResolveContext { readonly fromFile: string; readonly allFilePaths: ReadonlySet; + /** + * The pass's parsed Java files — the only input this resolver needs, because + * the package index is built from their declarations. + * + * Absent means "no workspace was supplied", not "the workspace declares + * nothing": the index would be empty and every import would answer `null`. + * The orchestrator always supplies it (`scope-resolution/pipeline/run.ts` + * threads `context.parsedFiles`), and it must be passed THROUGH rather than + * copied — the memo below keys on the array's identity. + */ + readonly parsedFiles?: readonly ParsedFile[]; } +/** + * The package index, built once per pass and read by every import. + * + * Keyed on the `parsedFiles` array the orchestrator already threads through the + * pass, like PHP's `filesByDirectory` and Python's `parsedFileByPath`. The + * instrument that can see this memo fail counts element reads on that array — + * `countedParsedFiles` in `test/helpers/counting-file-set.ts`, asserted for + * every language by `import-target-index-reuse.contract.test.ts`. + */ +const getJavaPackageIndex = perFileSet( + (parsedFiles: readonly ParsedFile[]): JavaPackageIndex => + buildJavaPackageIndex(parsedFiles, getJavaPackageFact), +); + export function resolveJavaImportTarget( parsedImport: ParsedImport, workspaceIndex: WorkspaceIndex, -): string | null { +): string | readonly string[] | null { + const ctx = narrowContext(workspaceIndex); + if (ctx === null) return null; + if (parsedImport.kind === 'dynamic-unresolved') return null; + if (parsedImport.targetRaw === null || parsedImport.targetRaw === '') return null; + + const parsedFiles = ctx.parsedFiles; + if (parsedFiles === undefined || parsedFiles.length === 0) return null; + + return resolveJavaModule(parsedImport.targetRaw, getJavaPackageIndex(parsedFiles)); +} + +/** + * `WorkspaceIndex` is an opaque `unknown` placeholder in the shared contract; + * the orchestrator hands us a `JavaResolveContext`-shaped object. Narrow + * structurally rather than via a cast chain so unexpected shapes fail cleanly. + */ +function narrowContext(workspaceIndex: WorkspaceIndex): JavaResolveContext | null { const ctx = workspaceIndex as JavaResolveContext | undefined; if ( ctx === undefined || @@ -29,80 +107,5 @@ export function resolveJavaImportTarget( ) { return null; } - if (parsedImport.kind === 'dynamic-unresolved') return null; - if (parsedImport.targetRaw === null || parsedImport.targetRaw === '') return null; - - // Strip trailing `.*` for wildcard imports: `com.example.*` → `com.example` - let target = parsedImport.targetRaw; - if (target.endsWith('.*')) { - target = target.slice(0, -2); - } - - // Package path: `com.example.User` → `com/example/User` - const pathLike = target.replace(/\./g, '/'); - const suffix = `/${pathLike}`; - - let exactFile: string | null = null; - let suffixFile: string | null = null; - let directoryChild: string | null = null; - const dirPrefix = `${pathLike}/`; - const suffixDirPrefix = `/${dirPrefix}`; - - for (const raw of ctx.allFilePaths) { - const f = raw.replace(/\\/g, '/'); - if (!f.endsWith('.java')) continue; - if (f === `${pathLike}.java`) { - exactFile = raw; - break; - } - if (suffixFile === null && f.endsWith(`${suffix}.java`)) { - suffixFile = raw; - } - if (directoryChild === null) { - const atRoot = f.startsWith(dirPrefix); - const atNested = f.includes(suffixDirPrefix); - if (atRoot || atNested) { - const idx = atRoot ? 0 : f.indexOf(suffixDirPrefix) + 1; - const after = f.slice(idx + dirPrefix.length); - if (after.length > 0 && !after.includes('/')) { - directoryChild = raw; - } - } - } - } - - if (exactFile !== null) return exactFile; - if (suffixFile !== null) return suffixFile; - if (directoryChild !== null) return directoryChild; - - // Progressive prefix stripping — handles `import com.example.User;` - // in a repo laid out `User.java` (no `com/example/` prefix). - const segments = pathLike.split('/').filter(Boolean); - for (let skip = 1; skip < segments.length; skip++) { - const tail = segments.slice(skip).join('/'); - if (tail === '') continue; - const tailFile = `${tail}.java`; - const tailSuffix = `/${tailFile}`; - const tailDir = `${tail}/`; - const tailSuffixDir = `/${tailDir}`; - let tailDirectChild: string | null = null; - for (const raw of ctx.allFilePaths) { - const f = raw.replace(/\\/g, '/'); - if (!f.endsWith('.java')) continue; - if (f === tailFile) return raw; - if (f.endsWith(tailSuffix)) return raw; - if (tailDirectChild === null) { - const atRoot = f.startsWith(tailDir); - const atNested = f.includes(tailSuffixDir); - if (atRoot || atNested) { - const idx = atRoot ? 0 : f.indexOf(tailSuffixDir) + 1; - const after = f.slice(idx + tailDir.length); - if (after.length > 0 && !after.includes('/')) tailDirectChild = raw; - } - } - } - if (tailDirectChild !== null) return tailDirectChild; - } - - return null; + return ctx; } diff --git a/gitnexus/src/core/ingestion/languages/java/interpret.ts b/gitnexus/src/core/ingestion/languages/java/interpret.ts index 38d6128d4..721edbff8 100644 --- a/gitnexus/src/core/ingestion/languages/java/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/java/interpret.ts @@ -57,10 +57,11 @@ export function interpretJavaImport(captures: CaptureMatch): ParsedImport | null // `import static com.example.Utils.*;` // The source is the class path (e.g. `com.example.Utils`). // Resolution should target the class file, not a wildcard directory - // scan — `Utils.java` is the file that contains the static members. + // scan — keeping the type path unstarred also distinguishes it from a + // package wildcard when a same-named package exists. return { kind: 'wildcard', - targetRaw: sourceCap.text + '.*', + targetRaw: sourceCap.text, }; } default: diff --git a/gitnexus/src/core/ingestion/languages/java/lombok-synthesizer.ts b/gitnexus/src/core/ingestion/languages/java/lombok-synthesizer.ts new file mode 100644 index 000000000..3f2eeab9a --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/java/lombok-synthesizer.ts @@ -0,0 +1,539 @@ +/** + * Lombok accessor synthesizer for Java. + * + * Lombok generates getters/setters at compile time. They are absent from the + * AST, so calls like `obj.getOrderId()` on a `@Data` class would otherwise + * leave unresolved CALLS edges. This module walks the tree-sitter Java AST + * and synthesizes Method graph members for the accessors Lombok would emit + * under the supported subset. + * + * ## Supported subset (v1) + * - Proven `lombok.Data` / `lombok.Getter` / `lombok.Setter` (FQN or import). + * - Class- or field-level enable; `AccessLevel.NONE` disables. + * - Default JavaBeans naming; primitive `boolean isX` → `isX` / `setX`. + * - Access levels PUBLIC/PROTECTED/PRIVATE/PACKAGE. + * - `@Accessors(chain=true)` modeled as setter return = declaring type. + * - `@Accessors(fluent=true)` / `prefix=…`: omit affected accessors (names + * cannot be proven without full Lombok config). + * - External `lombok.config`: unsupported (may change semantics invisibly). + * + * ## Identity + * Owner lookup uses in-memory AST node ids only. Method ids are derived from + * the stable declaring-owner graph key (the Class node id's name segment), + * never from persisted tree-sitter node ids. + */ + +import type Parser from 'tree-sitter'; +import type { CaptureMatch } from 'gitnexus-shared'; +import { jvmGetterName, jvmSetterName } from '../jvm/beanspec.js'; +import { + createExistingMethodIndex, + createJvmAccessorSynthesis, + hasExistingMethod, + rememberExistingMethodRange, + type ExistingMethodIndex, + type PlannedJvmAccessor, + type PlannedJvmAccessorOwner, + type SyntheticAccessorResult, + type SyntheticVisibility, +} from '../jvm/accessor-synthesis.js'; + +const JAVA_TYPE_DECLS = new Set([ + 'class_declaration', + 'enum_declaration', + 'interface_declaration', + 'record_declaration', +]); + +// ── Public result types (ParsedSymbol / ParsedNode compatible) ──────────── + +export type LombokVisibility = SyntheticVisibility; +export type SyntheticSymbol = SyntheticAccessorResult['symbols'][number]; +export type SyntheticNode = SyntheticAccessorResult['nodes'][number]; +export type SyntheticRelationship = SyntheticAccessorResult['relationships'][number]; +export type LombokSynthesisResult = SyntheticAccessorResult; +export type PlannedLombokAccessor = PlannedJvmAccessor; + +export interface AccessorConfig { + enabled: boolean; + visibility: LombokVisibility; +} + +interface AccessorsOptions { + /** When true, JavaBeans get/set/is prefixes are not used — omit (unsupported). */ + fluent: boolean; + /** When true, field prefixes alter base names — omit (unsupported). */ + hasPrefix: boolean; + /** When true, setters return the declaring type instead of void. */ + chain: boolean; +} + +interface LombokField { + name: string; + type: string; + isStatic: boolean; + isFinal: boolean; + startLine: number; + endLine: number; + declaratorNode: Parser.SyntaxNode; + fieldGetter: AccessorConfig | null; + fieldSetter: AccessorConfig | null; + accessors: AccessorsOptions; + accessorsPresent: boolean; +} + +interface LombokClass { + node: Parser.SyntaxNode; + name: string; + classGetter: AccessorConfig | null; + classSetter: AccessorConfig | null; + classAccessors: AccessorsOptions; + fields: LombokField[]; + existingMethods: ExistingMethodIndex; +} + +const LOMBOK_ANNOTATION_PACKAGE = new Map([ + ['Data', 'lombok'], + ['Getter', 'lombok'], + ['Setter', 'lombok'], + ['Accessors', 'lombok.experimental'], + ['Tolerate', 'lombok.experimental'], +]); + +export function getterName(fieldName: string, fieldType: string): string { + return jvmGetterName(fieldName, fieldType === 'boolean'); +} + +export function setterName(fieldName: string, fieldType: string): string { + return jvmSetterName(fieldName, fieldType === 'boolean'); +} + +// ── Provenance / imports ────────────────────────────────────────────────── + +function annotationSimpleName(nameText: string): string { + return nameText.split('.').pop() ?? nameText; +} + +interface LombokImportIndex { + bySimple: Map; + starPackages: Set; + shadowedSimpleNames: Set; +} + +/** + * Compilation-unit imports only — Java `import` is never nested in a type body. + */ +function collectLombokImports(root: Parser.SyntaxNode): LombokImportIndex { + const bySimple = new Map(); + const starPackages = new Set(); + const shadowedSimpleNames = new Set(); + for (const child of root.children) { + if (!JAVA_TYPE_DECLS.has(child.type) && child.type !== 'annotation_type_declaration') continue; + const name = child.childForFieldName('name')?.text; + if (name) shadowedSimpleNames.add(name); + } + for (const child of root.children) { + if (child.type !== 'import_declaration') continue; + if (/^import\s+static\b/.test(child.text)) continue; + const text = child.text + .replace(/^import\s+/, '') + .replace(/;\s*$/, '') + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/\s+/g, '') + .trim(); + if (text === 'lombok.*') { + starPackages.add('lombok'); + } else if (text === 'lombok.experimental.*') { + starPackages.add('lombok.experimental'); + } else if (!text.endsWith('.*')) { + bySimple.set(annotationSimpleName(text), text); + } + } + return { bySimple, starPackages, shadowedSimpleNames }; +} + +function isProvenLombokAnnotation(nameText: string, imports: LombokImportIndex): boolean { + const simple = annotationSimpleName(nameText); + const packageName = LOMBOK_ANNOTATION_PACKAGE.get(simple); + if (packageName === undefined) return false; + if (nameText.includes('.')) return nameText === `${packageName}.${simple}`; + const imported = imports.bySimple.get(simple); + if (imported !== undefined) return imported === `${packageName}.${simple}`; + if (imports.shadowedSimpleNames.has(simple)) return false; + return imports.starPackages.has(packageName); +} + +// ── AccessLevel / Accessors structural parse ────────────────────────────── + +function parseAccessLevelToken(text: string): LombokVisibility | 'none' | null { + const simple = annotationSimpleName(text.trim()); + switch (simple) { + case 'PUBLIC': + return 'public'; + case 'PROTECTED': + return 'protected'; + case 'PRIVATE': + return 'private'; + case 'PACKAGE': + case 'MODULE': // treated as package-private for graph metadata + return 'package'; + case 'NONE': + return 'none'; + default: + return null; + } +} + +function findAccessLevelInAnnotation(ann: Parser.SyntaxNode): LombokVisibility | 'none' | null { + // Positional: @Getter(AccessLevel.PROTECTED) or @Getter(lombok.AccessLevel.NONE) + // Named: @Getter(value = AccessLevel.PRIVATE) + const stack: Parser.SyntaxNode[] = [...ann.children]; + while (stack.length > 0) { + const n = stack.pop(); + if (!n) break; + if (n.type === 'field_access' || n.type === 'identifier') { + const level = parseAccessLevelToken(n.text); + if (level !== null) return level; + } + for (const c of n.children) stack.push(c); + } + return null; +} + +function defaultAccessors(): AccessorsOptions { + return { fluent: false, hasPrefix: false, chain: false }; +} + +function parseAccessorsAnnotation(ann: Parser.SyntaxNode): AccessorsOptions { + const opts = defaultAccessors(); + const stack: Parser.SyntaxNode[] = [...ann.children]; + while (stack.length > 0) { + const n = stack.pop(); + if (!n) break; + if (n.type === 'element_value_pair') { + const key = + n.childForFieldName('key')?.text ?? n.children.find((c) => c.type === 'identifier')?.text; + const valueNode = + n.childForFieldName('value') ?? + n.children.find( + (c) => + c.type === 'true' || c.type === 'false' || c.type === 'element_value_array_initializer', + ); + if (key === 'fluent' && (valueNode?.type === 'true' || valueNode?.type === 'false')) { + opts.fluent = valueNode.type === 'true'; + } + if (key === 'chain' && (valueNode?.type === 'true' || valueNode?.type === 'false')) { + opts.chain = valueNode.type === 'true'; + } + if (key === 'prefix') opts.hasPrefix = true; + } + for (const c of n.children) stack.push(c); + } + const text = ann.text; + if (/\bprefix\s*=/.test(text)) opts.hasPrefix = true; + if (/\bfluent\s*=\s*true\b/.test(text)) opts.fluent = true; + if (/\bfluent\s*=\s*false\b/.test(text)) opts.fluent = false; + if (/\bchain\s*=\s*true\b/.test(text)) opts.chain = true; + if (/\bchain\s*=\s*false\b/.test(text)) opts.chain = false; + return opts; +} + +interface ParsedAnnotations { + getter: AccessorConfig | null; + setter: AccessorConfig | null; + accessors: AccessorsOptions; + accessorsPresent: boolean; + tolerate: boolean; +} + +function parseModifierAnnotations( + modifiersNode: Parser.SyntaxNode | null, + imports: LombokImportIndex, +): ParsedAnnotations { + const result: ParsedAnnotations = { + getter: null, + setter: null, + accessors: defaultAccessors(), + accessorsPresent: false, + tolerate: false, + }; + if (!modifiersNode) return result; + + for (const child of modifiersNode.children) { + if (child.type !== 'marker_annotation' && child.type !== 'annotation') continue; + const nameNode = child.childForFieldName('name'); + const nameText = nameNode?.text ?? ''; + if (!isProvenLombokAnnotation(nameText, imports)) continue; + const simple = annotationSimpleName(nameText); + + if (simple === 'Tolerate') { + result.tolerate = true; + continue; + } + if (simple === 'Accessors') { + result.accessors = parseAccessorsAnnotation(child); + result.accessorsPresent = true; + continue; + } + if (simple === 'Data') { + result.getter ??= { enabled: true, visibility: 'public' }; + result.setter ??= { enabled: true, visibility: 'public' }; + continue; + } + if (simple === 'Getter' || simple === 'Setter') { + const level = child.type === 'annotation' ? findAccessLevelInAnnotation(child) : null; + const cfg: AccessorConfig = + level === 'none' + ? { enabled: false, visibility: 'public' } + : { enabled: true, visibility: level ?? 'public' }; + if (simple === 'Getter') result.getter = cfg; + else result.setter = cfg; + } + } + return result; +} + +function mergeAccessors( + classOpts: AccessorsOptions, + fieldOpts: AccessorsOptions, + fieldAccessorsPresent: boolean, +): AccessorsOptions { + return fieldAccessorsPresent ? fieldOpts : classOpts; +} + +function effectiveAccessor( + classCfg: AccessorConfig | null, + fieldCfg: AccessorConfig | null, +): AccessorConfig | null { + if (fieldCfg !== null) return fieldCfg; + return classCfg; +} + +// ── Field / method collection ───────────────────────────────────────────── + +function parseFieldDeclaration( + fieldNode: Parser.SyntaxNode, + imports: LombokImportIndex, +): LombokField[] { + const typeNode = fieldNode.childForFieldName('type'); + const fieldType = typeNode?.text ?? 'Object'; + const modifiers = fieldNode.children.find((c) => c.type === 'modifiers') ?? null; + let isStatic = false; + let isFinal = false; + if (modifiers) { + for (const mod of modifiers.children) { + if (mod.text === 'static') isStatic = true; + else if (mod.text === 'final') isFinal = true; + } + } + const fieldAnn = parseModifierAnnotations(modifiers, imports); + + const declarators: Parser.SyntaxNode[] = []; + const declaratorField = fieldNode.childForFieldName('declarator'); + if (declaratorField) declarators.push(declaratorField); + for (const child of fieldNode.children) { + if (child.type === 'variable_declarator' && child !== declaratorField) { + declarators.push(child); + } + } + + const startLine = fieldNode.startPosition.row + 1; + const endLine = fieldNode.endPosition.row + 1; + const out: LombokField[] = []; + for (const declaratorNode of declarators) { + const nameNode = declaratorNode.childForFieldName('name'); + if (!nameNode) continue; + out.push({ + name: nameNode.text, + type: fieldType, + isStatic, + isFinal, + startLine, + endLine, + declaratorNode, + fieldGetter: fieldAnn.getter, + fieldSetter: fieldAnn.setter, + accessors: fieldAnn.accessors, + accessorsPresent: fieldAnn.accessorsPresent, + }); + } + return out; +} + +function methodArityRange(methodNode: Parser.SyntaxNode): { min: number; max: number } { + const params = methodNode.childForFieldName('parameters'); + if (!params) return { min: 0, max: 0 }; + let count = 0; + for (const child of params.namedChildren) { + if (child.type === 'spread_parameter') return { min: count, max: Number.POSITIVE_INFINITY }; + if (child.type === 'formal_parameter') count += 1; + } + return { min: count, max: count }; +} + +function collectExistingMethods( + classBody: Parser.SyntaxNode | null, + imports: LombokImportIndex, +): ExistingMethodIndex { + const index = createExistingMethodIndex('case-folded'); + if (!classBody) return index; + const scan = (container: Parser.SyntaxNode): void => { + for (const child of container.children) { + if (child.type === 'enum_body_declarations') { + scan(child); + continue; + } + if (child.type !== 'method_declaration') continue; + const mods = child.children.find((c) => c.type === 'modifiers') ?? null; + const ann = parseModifierAnnotations(mods, imports); + if (ann.tolerate) continue; + const nameNode = child.childForFieldName('name'); + if (!nameNode) continue; + const arity = methodArityRange(child); + rememberExistingMethodRange(index, nameNode.text, arity.min, arity.max); + } + }; + scan(classBody); + return index; +} + +const TYPE_BODIES = new Set(['class_body', 'enum_body']); + +function findTypeBody(node: Parser.SyntaxNode): Parser.SyntaxNode | null { + return node.children.find((c) => TYPE_BODIES.has(c.type)) ?? null; +} + +function findLombokClasses(root: Parser.SyntaxNode, imports: LombokImportIndex): LombokClass[] { + const classes: LombokClass[] = []; + + function walk(node: Parser.SyntaxNode): void { + if (node.type === 'class_declaration' || node.type === 'enum_declaration') { + const modifiers = node.children.find((c) => c.type === 'modifiers') ?? null; + const classAnn = parseModifierAnnotations(modifiers, imports); + const nameNode = node.childForFieldName('name'); + const className = nameNode?.text ?? ''; + if (className) { + const body = findTypeBody(node); + const fields: LombokField[] = []; + if (body) { + const collectFields = (container: Parser.SyntaxNode): void => { + for (const child of container.children) { + if (child.type === 'field_declaration') { + for (const f of parseFieldDeclaration(child, imports)) { + if (f.isStatic) continue; + fields.push(f); + } + } else if (child.type === 'enum_body_declarations') { + collectFields(child); + } + } + }; + collectFields(body); + } + + const anyFieldEnable = fields.some( + (f) => f.fieldGetter?.enabled === true || f.fieldSetter?.enabled === true, + ); + const classEnable = classAnn.getter?.enabled === true || classAnn.setter?.enabled === true; + + // Class-level NONE alone is not enable — getter/setter configs may be disabled + if (classEnable || anyFieldEnable) { + classes.push({ + node, + name: className, + classGetter: classAnn.getter, + classSetter: classAnn.setter, + classAccessors: classAnn.accessors, + fields, + existingMethods: collectExistingMethods(body, imports), + }); + } + } + } + for (const child of node.children) walk(child); + } + + walk(root); + return classes; +} + +function planAccessors(cls: LombokClass): PlannedLombokAccessor[] { + const planned: PlannedLombokAccessor[] = []; + for (const field of cls.fields) { + const accessors = mergeAccessors(cls.classAccessors, field.accessors, field.accessorsPresent); + // fluent/prefix change names — omit rather than invent wrong names + if (accessors.fluent || accessors.hasPrefix) continue; + + const getterCfg = effectiveAccessor(cls.classGetter, field.fieldGetter); + const setterCfg = effectiveAccessor(cls.classSetter, field.fieldSetter); + + if (getterCfg?.enabled) { + const gName = getterName(field.name, field.type); + if (!hasExistingMethod(cls.existingMethods, gName, 0)) { + planned.push({ + kind: 'getter', + name: gName, + returnType: field.type, + parameterTypes: [], + visibility: getterCfg.visibility, + isStatic: false, + isAbstract: false, + startLine: field.startLine, + endLine: field.endLine, + declaratorNode: field.declaratorNode, + }); + } + } + + if (setterCfg?.enabled && !field.isFinal) { + const sName = setterName(field.name, field.type); + if (!hasExistingMethod(cls.existingMethods, sName, 1)) { + // chain=true → setter returns declaring type; never emit void in that case + const returnType = accessors.chain ? cls.name : 'void'; + planned.push({ + kind: 'setter', + name: sName, + returnType, + parameterTypes: [field.type], + visibility: setterCfg.visibility, + isStatic: false, + isAbstract: false, + startLine: field.startLine, + endLine: field.endLine, + declaratorNode: field.declaratorNode, + }); + } + } + } + return planned; +} + +function planLombokAccessorOwners(root: Parser.SyntaxNode): PlannedJvmAccessorOwner[] { + const imports = collectLombokImports(root); + return findLombokClasses(root, imports).map((cls) => ({ + node: cls.node, + name: cls.name, + accessors: planAccessors(cls), + })); +} + +const lombokAccessorSynthesis = createJvmAccessorSynthesis({ + language: 'java', + synthetic: 'lombok', + planOwners: planLombokAccessorOwners, +}); + +// ── Main API ────────────────────────────────────────────────────────────── + +export function synthesizeLombokAccessors( + tree: Parser.Tree, + filePath: string, + classOwnersById: ReadonlyMap, +): LombokSynthesisResult { + return lombokAccessorSynthesis.synthesize(tree, filePath, classOwnersById); +} + +/** Scope captures for Lombok accessors (dual-path parity with record components). */ +export function synthesizeLombokAccessorCaptures(rootNode: Parser.SyntaxNode): CaptureMatch[] { + return lombokAccessorSynthesis.captures(rootNode); +} diff --git a/gitnexus/src/core/ingestion/languages/java/module-resolution.ts b/gitnexus/src/core/ingestion/languages/java/module-resolution.ts new file mode 100644 index 000000000..9eea50d74 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/java/module-resolution.ts @@ -0,0 +1,167 @@ +/** + * Java import resolution against DECLARED packages (#2953). + * + * A Java import is a fully-qualified type name, not a path. `com.example.model.User` + * names the type `User` in the package `com.example.model`, and what places a + * file in that package is its own `package` declaration — not where it sits on + * disk. A file at `weird/path/User.java` declaring `package com.example.model;` + * IS `com.example.model.User`; a file at `com/example/model/User.java` declaring + * nothing is in the DEFAULT package and cannot be imported at all. + * + * The previous resolver worked the other way round: it turned dots into slashes + * and looked for a file whose path ended that way, retrying with each leading + * segment stripped. Path shape is a convention, so that mostly worked — and + * failed in the one case that matters most, because it could not tell an import + * of something outside the repository from one inside it. `java.util.List` + * became `util/List`, then `List`, and bound to any `List.java` in the tree. + * Every JDK and third-party import in a repo was a candidate for a fabricated + * IMPORTS edge at full confidence. + * + * The fix needs no new I/O. Every Java file's `package` declaration is already + * extracted during the parse pass and available here through + * `getJavaPackageFact` — the resolver simply never read it. So resolution + * becomes a lookup in an index the workspace already knows how to describe: + * + * `com.example.model.User` -> package `com.example.model` declares `User` + * `java.util.List` -> no file declares package `java.util` -> null + * + * `null` for the second is the complete and correct answer: the JDK is not in + * this repository, so there is no in-repo file the import could name. + */ + +import type { ParsedFile } from 'gitnexus-shared'; +import type { JvmPackageFact } from '../jvm/package-facts.js'; + +export interface JavaPackageIndex { + /** Declared package -> importable type name -> the file declaring it. */ + readonly typesByPackage: ReadonlyMap>; + /** Declared package -> every file declaring it, for wildcard imports. */ + readonly filesByPackage: ReadonlyMap; + /** + * Files whose `package` header could not be read (a malformed header — see + * `extractJvmPackageFact`). They are in no package, so nothing can import + * them; counted so the gap is observable rather than silent. + */ + readonly unreadablePackageFiles: number; +} + +const EMPTY_INDEX: JavaPackageIndex = { + typesByPackage: new Map(), + filesByPackage: new Map(), + unreadablePackageFiles: 0, +}; + +/** + * Index the workspace by what each file DECLARES. + * + * The importable type name is the file's base name, which is not a convention + * being relied on but the rule the language enforces: a type importable from + * another package must be `public`, and a public type must live in a file named + * after it. Additional package-private top-level types in the same file are + * deliberately not indexed — they are unimportable from elsewhere, so an import + * naming one is not a resolution this should find. + */ +export function buildJavaPackageIndex( + parsedFiles: readonly ParsedFile[], + packageOf: (filePath: string) => JvmPackageFact | undefined, +): JavaPackageIndex { + if (parsedFiles.length === 0) return EMPTY_INDEX; + + const typesByPackage = new Map>(); + const filesByPackage = new Map(); + let unreadablePackageFiles = 0; + + for (const parsed of parsedFiles) { + const filePath = parsed.filePath; + const fact = packageOf(filePath); + if (fact === undefined) continue; + if (fact.status !== 'known') { + unreadablePackageFiles++; + continue; + } + // The default package (`''`) is indexed like any other so a workspace of + // package-less files still answers its own wildcards, but Java forbids + // importing FROM it, which `resolveJavaModule` enforces rather than + // pretending here that the entry does not exist. + const packageName = fact.packageName; + + const typeName = baseTypeName(filePath); + if (typeName !== null) { + let types = typesByPackage.get(packageName); + if (types === undefined) { + types = new Map(); + typesByPackage.set(packageName, types); + } + // First declaration wins. Two files claiming the same package+type is not + // legal Java; picking either is as correct as the input allows. + if (!types.has(typeName)) types.set(typeName, filePath); + } + + const files = filesByPackage.get(packageName); + if (files === undefined) filesByPackage.set(packageName, [filePath]); + else files.push(filePath); + } + + return { typesByPackage, filesByPackage, unreadablePackageFiles }; +} + +/** + * Resolve one import specifier to the file(s) it names, or `null`. + * + * A wildcard answers with every file in the package; a type import answers with + * one file. Anything the workspace does not declare answers `null`. + */ +export function resolveJavaModule( + targetRaw: string, + index: JavaPackageIndex, +): string | readonly string[] | null { + if (targetRaw === '') return null; + + if (targetRaw.endsWith('.*')) { + const stem = targetRaw.slice(0, -2); + const inPackage = index.filesByPackage.get(stem); + // Only package wildcards retain `.*`. Static wildcards are interpreted with + // the owning type path so a same-named package cannot capture the import. + return inPackage !== undefined && stem !== '' ? inPackage : null; + } + + return resolveTypeName(targetRaw, index); +} + +/** + * Split a qualified name into the longest DECLARED package prefix and the type + * that follows it. + * + * Longest-first is what makes both of these land correctly without a rule about + * capitalization, which Java does not actually enforce: + * + * `com.example.model.User` -> package `com.example.model`, type `User` + * `com.example.Utils.method` -> package `com.example`, type `Utils` + * + * The second is a static member import; its trailing segments name members + * inside the type, and the file the import binds to is the type's. + */ +function resolveTypeName(qualified: string, index: JavaPackageIndex): string | null { + const parts = qualified.split('.').filter((part) => part !== ''); + // A single bare segment names a type in the default package, which Java + // forbids importing. Nothing to resolve, and nothing to guess at. + if (parts.length < 2) return null; + + for (let split = parts.length - 1; split >= 1; split--) { + const packageName = parts.slice(0, split).join('.'); + const types = index.typesByPackage.get(packageName); + if (types === undefined) continue; + const file = types.get(parts[split]); + if (file !== undefined) return file; + } + return null; +} + +/** `src/main/java/com/example/User.java` -> `User`. */ +function baseTypeName(filePath: string): string | null { + const slash = filePath.replace(/\\/g, '/').lastIndexOf('/'); + const base = slash === -1 ? filePath : filePath.slice(slash + 1); + if (!base.endsWith('.java')) return null; + const name = base.slice(0, -'.java'.length); + return name === '' ? null : name; +} diff --git a/gitnexus/src/core/ingestion/languages/java/query.ts b/gitnexus/src/core/ingestion/languages/java/query.ts index 99c72dc09..507d3ce79 100644 --- a/gitnexus/src/core/ingestion/languages/java/query.ts +++ b/gitnexus/src/core/ingestion/languages/java/query.ts @@ -89,7 +89,13 @@ const JAVA_SCOPE_QUERY = ` ])) @class-annotation.class ;; Declarations — methods / constructors +;; +;; A generic METHOD's parameters are read for the same reason a generic type's +;; are (#2912 review): \` boolean runAny(Validator v)\` writes a receiver +;; whose argument is a type VARIABLE, and a pass that cannot tell that from a +;; concrete type prunes every implementor from the call's dispatch fan-out. (method_declaration + type_parameters: (type_parameters)? @declaration.type-parameters name: (identifier) @declaration.name) @declaration.method (constructor_declaration diff --git a/gitnexus/src/core/ingestion/languages/java/record-components.ts b/gitnexus/src/core/ingestion/languages/java/record-components.ts new file mode 100644 index 000000000..51053510c --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/java/record-components.ts @@ -0,0 +1,233 @@ +import { SupportedLanguages, type CaptureMatch } from 'gitnexus-shared'; +import type { CaptureMap } from '../../language-provider.js'; +import { createMethodExtractor } from '../../method-extractors/generic.js'; +import { javaMethodConfig } from '../../method-extractors/configs/jvm.js'; +import { extractAnnotations } from '../../field-extractors/configs/helpers.js'; +import type { + ExtractedMethods, + MethodExtractor, + MethodExtractorContext, + MethodInfo, +} from '../../method-types.js'; +import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; + +const javaExplicitMethodExtractor = createMethodExtractor(javaMethodConfig); + +function recordComponents(recordNode: SyntaxNode): SyntaxNode[] { + const parameters = recordNode.childForFieldName('parameters'); + if (parameters === null) return []; + return parameters.namedChildren.filter( + (node): node is SyntaxNode => + node !== null && (node.type === 'formal_parameter' || node.type === 'spread_parameter'), + ); +} + +/** + * A record component is named by a real `identifier` and nothing else. + * + * Two node shapes reach this that are not one, and both would mint a graph node + * for source that does not compile: + * + * - `record M(int x, y) {}` — a dropped type. tree-sitter recovers by + * synthesizing `name: (MISSING identifier)`, a zero-width node whose text is + * `''`. It still satisfies the query's `name: (identifier)`, so testing the + * node TYPE alone does not reject it. + * - `record R(int _) {}` — the grammar declares both `formal_parameter.name` + * and `variable_declarator.name` as `identifier | underscore_pattern`, and + * `_` parses with no error at all. `_` is illegal as a component name, and + * admitting it here while the query rejects it is what let the structure and + * scope paths disagree. + * + * Same degenerate-node shape as `javaBaseLookupNameNode` in captures.ts (#2935). + */ +function isRecordComponentName(node: SyntaxNode | null | undefined): node is SyntaxNode { + return ( + node !== null && + node !== undefined && + node.type === 'identifier' && + !node.isMissing && + node.text.length > 0 + ); +} + +function recordComponentNameNode(component: SyntaxNode): SyntaxNode | null { + const name = + component.type === 'formal_parameter' + ? component.childForFieldName('name') + : (component.namedChildren + .find((node) => node?.type === 'variable_declarator') + ?.childForFieldName('name') ?? null); + return isRecordComponentName(name) ? name : null; +} + +/** + * Memoised per record node. `shouldSkipJavaRecordComponentDefinition` is called + * once per component capture, so recomputing this would rescan the whole record + * body per component — O(components x body members) for a single record. The + * scope-capture path hoists the call out of its own loop instead; this cache is + * what gives the structure path the same cost. Keyed weakly on the AST node, so + * it drops with the tree at the end of the file's parse. + */ +const explicitZeroArgAccessorNamesCache = new WeakMap>(); + +function explicitZeroArgAccessorNames(recordNode: SyntaxNode): Set { + const memoized = explicitZeroArgAccessorNamesCache.get(recordNode); + if (memoized !== undefined) return memoized; + const names = computeExplicitZeroArgAccessorNames(recordNode); + explicitZeroArgAccessorNamesCache.set(recordNode, names); + return names; +} + +function computeExplicitZeroArgAccessorNames(recordNode: SyntaxNode): Set { + const names = new Set(); + const body = recordNode.childForFieldName('body'); + if (body === null) return names; + + for (const node of body.namedChildren) { + if (node === null || node.type !== 'method_declaration') continue; + const name = node.childForFieldName('name')?.text; + const parameters = node.childForFieldName('parameters'); + const parameterCount = + parameters?.namedChildren.filter( + (parameter) => + parameter !== null && + (parameter.type === 'formal_parameter' || parameter.type === 'spread_parameter'), + ).length ?? 0; + if (name !== undefined && parameterCount === 0) names.add(name); + } + return names; +} + +function recordComponentReturnType(component: SyntaxNode): string | null { + const typeNode = + component.childForFieldName('type') ?? + (component.type === 'spread_parameter' + ? component.namedChildren.find( + (node) => node?.type !== 'modifiers' && node?.type !== 'variable_declarator', + ) + : undefined); + const type = typeNode?.text; + if (type === undefined) return null; + return component.type === 'spread_parameter' ? `${type}[]` : type; +} + +function implicitAccessorInfo( + component: SyntaxNode, + context: MethodExtractorContext, +): MethodInfo | null { + const name = recordComponentNameNode(component)?.text; + if (name === undefined) return null; + + return { + name, + receiverType: null, + returnType: recordComponentReturnType(component), + parameters: [], + visibility: 'public', + isStatic: false, + isAbstract: false, + isFinal: false, + // JLS 8.10.3 / 9.7.4: a component annotation reaches the generated accessor + // when its @Target admits METHOD (or TYPE_USE, in the return-type position). + // ponytail: over-approximate — we propagate every component annotation, + // because @Target lives in another file and parsing is per-file, so the + // target set is not knowable here. Nothing reads Method annotations today: + // `annotations` is not a column in METHOD_SCHEMA/FUNCTION_SCHEMA + // (src/core/lbug/schema.ts), so it lives only in the in-memory graph for one + // analyze run, and the sole in-memory reader (springDiFieldMatcher) is gated + // to `Property` nodes. If that column is ever added, revisit this: the set + // would then become an agent-visible claim that may over-state the target. + annotations: extractAnnotations(component, 'modifiers'), + sourceFile: context.filePath, + line: component.startPosition.row + 1, + column: component.startPosition.column, + }; +} + +/** Java records synthesize one public, zero-argument accessor per component. */ +export const javaRecordMethodExtractor: MethodExtractor = { + ...javaExplicitMethodExtractor, + language: SupportedLanguages.Java, + extract(node: SyntaxNode, context: MethodExtractorContext): ExtractedMethods | null { + const extracted = javaExplicitMethodExtractor.extract(node, context); + if (extracted === null || node.type !== 'record_declaration') return extracted; + + const explicitAccessors = explicitZeroArgAccessorNames(node); + const implicitAccessors = recordComponents(node) + .filter((component) => { + const name = recordComponentNameNode(component)?.text; + return name !== undefined && !explicitAccessors.has(name); + }) + .map((component) => implicitAccessorInfo(component, context)) + .filter((method): method is MethodInfo => method !== null); + + return { ...extracted, methods: [...extracted.methods, ...implicitAccessors] }; + }, +}; + +/** Scope declarations matching the structure-phase synthetic accessor nodes. */ +export function synthesizeJavaRecordComponentAccessorCaptures( + rootNode: SyntaxNode, +): CaptureMatch[] { + const captures: CaptureMatch[] = []; + for (const recordNode of rootNode.descendantsOfType('record_declaration')) { + const explicitAccessors = explicitZeroArgAccessorNames(recordNode); + for (const component of recordComponents(recordNode)) { + const nameNode = recordComponentNameNode(component); + const returnType = recordComponentReturnType(component); + if (nameNode === null || returnType === null || explicitAccessors.has(nameNode.text)) + continue; + + captures.push({ + '@scope.function': nodeToCapture('@scope.function', component), + }); + captures.push({ + '@declaration.method': nodeToCapture('@declaration.method', component), + '@declaration.name': nodeToCapture('@declaration.name', nameNode), + '@declaration.parameter-count': syntheticCapture( + '@declaration.parameter-count', + component, + '0', + ), + '@declaration.required-parameter-count': syntheticCapture( + '@declaration.required-parameter-count', + component, + '0', + ), + '@declaration.return-type': syntheticCapture( + '@declaration.return-type', + component, + returnType, + ), + }); + } + } + return captures; +} + +/** + * The structure query sees every record component. Suppress that synthetic + * definition when the record body provides the canonical zero-argument + * accessor explicitly, leaving the explicit method as the single authority. + */ +export function shouldSkipJavaRecordComponentDefinition(captureMap: CaptureMap): boolean { + const component = captureMap['definition.method']; + if (component?.type !== 'formal_parameter' && component?.type !== 'spread_parameter') { + return false; + } + + const parameters = component.parent; + const recordNode = parameters?.parent; + if (parameters?.type !== 'formal_parameters' || recordNode?.type !== 'record_declaration') { + return false; + } + + // Same predicate the scope path applies, so the two can never disagree about + // which components have an accessor. The query's `name: (identifier)` is + // satisfied by tree-sitter's zero-width MISSING recovery token, so the + // structure path has to re-check what the query cannot express. + const nameNode = captureMap['name']; + if (!isRecordComponentName(nameNode)) return true; + + return explicitZeroArgAccessorNames(recordNode).has(nameNode.text); +} diff --git a/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts index 86c94bccb..3f23040a6 100644 --- a/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts @@ -34,6 +34,8 @@ import { attachJavaSpringAopMetadata } from './spring-aop.js'; import { attachJavaSpringConfigBindings } from './spring-config-bindings.js'; import { attachJavaSpringConditionalMetadata } from './spring-conditionals.js'; import { attachJavaSpringDiMetadata } from './spring-di.js'; +import { attachJavaSpringNonHttpHandlerMetadata } from './spring-non-http-handlers.js'; +import { attachJavaSpringDynamicLookup } from './spring-dynamic-lookup.js'; import { applyJavaCaptureSideChannel, clearJavaClassAnnotationFacts, @@ -54,8 +56,10 @@ const javaScopeResolver: ScopeResolver = { return undefined; }, - resolveImportTarget: (targetRaw, fromFile, allFilePaths) => { - const ws: JavaResolveContext = { fromFile, allFilePaths }; + resolveImportTarget: (targetRaw, fromFile, allFilePaths, _resolutionConfig, context) => { + // `context.parsedFiles` is the whole input now: a Java import names a type + // in a DECLARED package, and the declarations live on those files (#2953). + const ws: JavaResolveContext = { fromFile, allFilePaths, parsedFiles: context?.parsedFiles }; return resolveJavaImportTarget( { kind: 'named', localName: '_', importedName: '_', targetRaw }, ws, @@ -92,7 +96,9 @@ const javaScopeResolver: ScopeResolver = { attachJavaSpringAopMetadata(graph, parsedFiles, nodeLookup, indexes); attachJavaSpringConditionalMetadata(graph, parsedFiles, nodeLookup, indexes); attachJavaSpringDiMetadata(graph, parsedFiles, nodeLookup, indexes); + attachJavaSpringNonHttpHandlerMetadata(graph, parsedFiles, nodeLookup, indexes); attachJavaSpringConfigBindings(graph, parsedFiles, nodeLookup, indexes, ctx); + attachJavaSpringDynamicLookup(graph, parsedFiles, nodeLookup, indexes); }, }; diff --git a/gitnexus/src/core/ingestion/languages/java/spring-actuator.ts b/gitnexus/src/core/ingestion/languages/java/spring-actuator.ts new file mode 100644 index 000000000..dd3627315 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/java/spring-actuator.ts @@ -0,0 +1,72 @@ +import type { GraphNode } from 'gitnexus-shared'; +import type { RuntimeCallableIdentity, RuntimeSymbolStrategy } from '../../language-provider.js'; + +const JVM_PRIMITIVES: Readonly> = { + B: 'byte', + C: 'char', + D: 'double', + F: 'float', + I: 'int', + J: 'long', + S: 'short', + Z: 'boolean', +}; + +function normalizedType(value: string, runtime: boolean): string { + let erased = value.trim(); + let arrayDimensions = 0; + if (erased.endsWith('...')) { + arrayDimensions++; + erased = erased.slice(0, -3); + } + while (erased.endsWith('[]')) { + arrayDimensions++; + erased = erased.slice(0, -2); + } + erased = erased.replace(/<.*>$/, '').replaceAll('$', '.').replaceAll('/', '.'); + const simple = erased.slice(erased.lastIndexOf('.') + 1); + const base = runtime ? (JVM_PRIMITIVES[simple] ?? simple) : simple; + return `${base}${'[]'.repeat(arrayDimensions)}`; +} + +function sourceTypeIsUnknown(value: string): boolean { + const type = normalizedType(value, false).replace(/(?:\[\])+$/, ''); + return type === '?' || /^[A-Z]$/.test(type); +} + +function matchesJavaCallable(node: GraphNode, runtime: RuntimeCallableIdentity): boolean { + if (node.label !== 'Method' || node.properties.name !== runtime.name) return false; + + const descriptorTypes = runtime.descriptorParameterTypes; + if (descriptorTypes === undefined) return true; + + const parameterCount = node.properties.parameterCount; + if (typeof parameterCount === 'number' && parameterCount !== descriptorTypes.length) return false; + + const sourceTypes = node.properties.parameterTypes; + if ( + !Array.isArray(sourceTypes) || + sourceTypes.length !== descriptorTypes.length || + !sourceTypes.every((type): type is string => typeof type === 'string') + ) { + return true; + } + + return sourceTypes.every((sourceType, index) => { + if (sourceTypeIsUnknown(sourceType)) return true; + const source = normalizedType(sourceType, false); + const descriptor = normalizedType(descriptorTypes[index] ?? '', true); + if (source === descriptor) return true; + // Java parser metadata currently drops the ellipsis from varargs and also + // leaves parameterCount open-ended. Only in that shape may T match JVM T[]. + return ( + typeof parameterCount !== 'number' && + descriptor.endsWith('[]') && + source === descriptor.slice(0, -2) + ); + }); +} + +export const javaRuntimeSymbolStrategy: RuntimeSymbolStrategy = { + matchesCallable: matchesJavaCallable, +}; diff --git a/gitnexus/src/core/ingestion/languages/java/spring-di.ts b/gitnexus/src/core/ingestion/languages/java/spring-di.ts index c6dcbe261..121cd1e3b 100644 --- a/gitnexus/src/core/ingestion/languages/java/spring-di.ts +++ b/gitnexus/src/core/ingestion/languages/java/spring-di.ts @@ -12,13 +12,72 @@ import { hasSpringBeanFactorySyntax, type SpringBeanFactoryMethodFact, } from '../../frameworks/spring/bean-factories.js'; +import { + normalizeSpringFactText, + type SpringArgumentFact, +} from '../../frameworks/spring/argument-facts.js'; import { parseSpringInjectionType } from '../../di-extractors/spring.js'; -import { nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; +import { hasRecoveredSyntax, nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; import { isJavaPackageSiblingVisibilityIncomplete } from './package-siblings.js'; import { getJavaSpringDiFacts } from './capture-side-channel.js'; export interface JavaAnnotationSyntaxFact extends SpringDiAnnotationFact { readonly line: number; + /** Present only for callers that opt in via `javaSpringAnnotationFacts`. */ + readonly args?: readonly SpringArgumentFact[]; +} + +/** + * Options for `javaSpringAnnotationFacts`. + * + * The STRUCTURED arguments are opt-in because DI captures every annotated + * field, constructor, and method in the repository, and none of its consumers + * reads them. Note what this does and does not save: every fact already carries + * `text`, the annotation's full source, so the argument TEXT crosses the worker + * boundary either way. What the opt-in avoids is a second, parsed copy of that + * same text on facts that would never look at it. + */ +export interface JavaSpringAnnotationFactOptions { + readonly includeArguments?: boolean; +} + +const JAVA_COMMENT_NODE_TYPES = new Set(['line_comment', 'block_comment']); + +/** + * Annotation arguments as written, or `undefined` for a marker annotation. + * + * `@Scheduled` yields `undefined` (no argument list in the syntax) while + * `@Scheduled()` yields `[]` (an empty list was written). Named arguments keep + * their key, single-element ones stay positional, and array initializers are + * kept as one raw `{...}` text — splitting or dereferencing them would be + * resolution, which does not belong at capture time. + * + * An argument list that did not parse also yields `undefined`. Error recovery + * fills gaps with invented nodes — `@KafkaListener(topics = "orders", groupId =` + * hands back a `groupId` whose value is a `{}` that nobody wrote — and there is + * no fourth state here for "unreadable". Collapsing it into the marker case is + * deliberate: both tell a consumer there is nothing here to resolve, which is + * true, whereas a fabricated value would send it somewhere real and wrong. + */ +function javaAnnotationArgumentFacts(annotation: SyntaxNode): SpringArgumentFact[] | undefined { + const argumentList = annotation.childForFieldName('arguments'); + if (argumentList === null || hasRecoveredSyntax(argumentList)) return undefined; + const args: SpringArgumentFact[] = []; + for (const child of argumentList.namedChildren) { + if (JAVA_COMMENT_NODE_TYPES.has(child.type)) continue; + if (child.type === 'element_value_pair') { + const key = child.childForFieldName('key'); + const value = child.childForFieldName('value'); + if (key === null || value === null) { + args.push({ text: normalizeSpringFactText(child.text) }); + continue; + } + args.push({ name: key.text.trim(), text: normalizeSpringFactText(value.text) }); + continue; + } + args.push({ text: normalizeSpringFactText(child.text) }); + } + return args; } export type JavaSpringDependencyFact = SpringDiDependencyFact; @@ -36,7 +95,10 @@ export type JavaSpringDiClassFact = SpringDiClassFact< >; type JavaSpringBeanFactoryMethodFact = SpringBeanFactoryMethodFact; -export function javaSpringAnnotationFacts(node: SyntaxNode): JavaAnnotationSyntaxFact[] { +export function javaSpringAnnotationFacts( + node: SyntaxNode, + options: JavaSpringAnnotationFactOptions = {}, +): JavaAnnotationSyntaxFact[] { const facts: JavaAnnotationSyntaxFact[] = []; for (const child of node.namedChildren) { if (child.type !== 'modifiers') continue; @@ -44,10 +106,13 @@ export function javaSpringAnnotationFacts(node: SyntaxNode): JavaAnnotationSynta if (modifier.type !== 'marker_annotation' && modifier.type !== 'annotation') continue; const nameNode = modifier.childForFieldName('name') ?? modifier.firstNamedChild; if (nameNode === null) continue; + const args = + options.includeArguments === true ? javaAnnotationArgumentFacts(modifier) : undefined; facts.push({ name: nameNode.text.trim(), text: modifier.text.trim(), line: modifier.startPosition.row + 1, + ...(args === undefined ? {} : { args }), }); } } diff --git a/gitnexus/src/core/ingestion/languages/java/spring-dynamic-lookup.ts b/gitnexus/src/core/ingestion/languages/java/spring-dynamic-lookup.ts new file mode 100644 index 000000000..922264bf8 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/java/spring-dynamic-lookup.ts @@ -0,0 +1,77 @@ +import { makeScopeId } from 'gitnexus-shared'; +import { + createSpringDynamicLookupMetadataAttacher, + springDynamicLookupCardinality, + type SpringDynamicLookupFact, +} from '../../frameworks/spring/dynamic-lookups.js'; +import { + findAncestorBeforeBoundary, + nodeToCapture, + type SyntaxNode, +} from '../../utils/ast-helpers.js'; +import { getJavaSpringDynamicLookupFacts } from './capture-side-channel.js'; + +const CALLABLE_NODE_TYPES = new Set([ + 'method_declaration', + 'constructor_declaration', + 'compact_constructor_declaration', +]); +const NO_CALLABLE_BOUNDARIES = new Set(); + +function classLiteralTypeName(argument: SyntaxNode): string | null { + if (argument.type !== 'class_literal' || argument.namedChildCount !== 1) return null; + return argument.namedChild(0)?.text.trim() ?? null; +} + +/** Capture real Java method invocations; comments and literals are never visited as calls. */ +export function captureJavaSpringDynamicLookupFact( + node: SyntaxNode, + filePath: string, +): SpringDynamicLookupFact | null { + if (node.type !== 'method_invocation') return null; + const receiverName = node.childForFieldName('object')?.text.trim(); + const methodName = node.childForFieldName('name')?.text.trim(); + const argumentsNode = node.childForFieldName('arguments'); + if (receiverName === undefined || methodName === undefined || argumentsNode === null) return null; + if (springDynamicLookupCardinality(receiverName, methodName) === null) return null; + + const argumentsWithoutComments = argumentsNode.namedChildren.filter( + (child) => child.type !== 'line_comment' && child.type !== 'block_comment', + ); + if (argumentsWithoutComments.length !== 1) return null; + const argument = argumentsWithoutComments[0]; + if (argument === undefined) return null; + const targetTypeName = classLiteralTypeName(argument); + if (targetTypeName === null) return null; + + const owner = findAncestorBeforeBoundary(node, CALLABLE_NODE_TYPES, NO_CALLABLE_BOUNDARIES); + if (owner === null) return null; + const ownerCapture = nodeToCapture('@spring-dynamic-lookup.owner', owner); + return { + ownerScopeId: makeScopeId({ + filePath, + range: ownerCapture.range, + kind: 'Function', + }), + ownerRange: ownerCapture.range, + receiverName, + methodName, + targetTypeName, + }; +} + +/** Standalone extractor for focused tests; production reuses scope-query call nodes. */ +export function captureJavaSpringDynamicLookupFacts( + rootNode: SyntaxNode, + filePath: string, +): SpringDynamicLookupFact[] { + return rootNode + .descendantsOfType('method_invocation') + .map((node) => captureJavaSpringDynamicLookupFact(node, filePath)) + .filter((fact): fact is SpringDynamicLookupFact => fact !== null); +} + +/** Attach Java lookup facts for later resolution by the shared DI phase. */ +export const attachJavaSpringDynamicLookup = createSpringDynamicLookupMetadataAttacher({ + getFacts: getJavaSpringDynamicLookupFacts, +}); diff --git a/gitnexus/src/core/ingestion/languages/java/spring-message-producers.ts b/gitnexus/src/core/ingestion/languages/java/spring-message-producers.ts new file mode 100644 index 000000000..ce59015ba --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/java/spring-message-producers.ts @@ -0,0 +1,100 @@ +import { makeScopeId } from 'gitnexus-shared'; +import { + normalizeSpringFactText, + type SpringArgumentFact, +} from '../../frameworks/spring/argument-facts.js'; +import { + isSpringMessageProducerMethod, + springMessageProducerTemplateOf, + type SpringMessageProducerFact, +} from '../../frameworks/spring/message-producers.js'; +import { + findAncestorBeforeBoundary, + hasRecoveredSyntax, + nodeToCapture, + type SyntaxNode, +} from '../../utils/ast-helpers.js'; + +const CALLABLE_NODE_TYPES = new Set([ + 'method_declaration', + 'constructor_declaration', + 'compact_constructor_declaration', +]); +/** + * A type body ends the search for the publishing callable. + * + * Without it the ancestor walk passes THROUGH the body of a class declared + * inside a method, so a publish in that class's field initializer is attributed + * to the enclosing method, which may never run it. The identical construct at + * the top level of a class already yields no fact — there is no enclosing + * callable to find — and the rule has to read the same at every depth. + */ +const TYPE_BODY_BOUNDARIES = new Set([ + 'class_body', + 'interface_body', + 'enum_body', + 'enum_body_declarations', + 'annotation_type_body', +]); +const COMMENT_NODE_TYPES = new Set(['line_comment', 'block_comment']); + +/** Java has no named call arguments, so every argument is captured positionally. */ +function javaCallArgumentFacts(argumentList: SyntaxNode): SpringArgumentFact[] { + return argumentList.namedChildren + .filter((child) => !COMMENT_NODE_TYPES.has(child.type)) + .map((child) => ({ text: normalizeSpringFactText(child.text) })); +} + +/** + * Capture one messaging-template publish from a Java call already surfaced by + * the scope query, without resolving the destination it names. + * + * The destination argument may be a literal, a reference to a constant that + * lives in another file, or a `${...}` placeholder resolved from configuration; + * all three are recorded as written and left to a later phase. + * + * A call whose argument list did not parse yields NO fact. The fact exists to + * carry a destination, and error recovery invents argument boundaries — an + * unterminated `send(TOPIC,` absorbs the next declaration's source and offers + * it as an argument. There is no state on this fact that means "published + * somewhere unreadable", so the choice is between silence and a plausible lie, + * and silence is recoverable: the file is re-captured when it parses. + */ +export function captureJavaSpringMessageProducerFact( + node: SyntaxNode, + filePath: string, +): SpringMessageProducerFact | null { + if (node.type !== 'method_invocation') return null; + const methodName = node.childForFieldName('name')?.text.trim(); + if (methodName === undefined || !isSpringMessageProducerMethod(methodName)) return null; + const receiverText = node.childForFieldName('object')?.text; + if (receiverText === undefined) return null; + const receiverName = normalizeSpringFactText(receiverText); + const template = springMessageProducerTemplateOf(receiverName, methodName); + if (template === null) return null; + + const argumentList = node.childForFieldName('arguments'); + if (argumentList !== null && hasRecoveredSyntax(argumentList)) return null; + const owner = findAncestorBeforeBoundary(node, CALLABLE_NODE_TYPES, TYPE_BODY_BOUNDARIES); + if (owner === null) return null; + const ownerCapture = nodeToCapture('@spring-message-producer.owner', owner); + return { + ownerScopeId: makeScopeId({ filePath, range: ownerCapture.range, kind: 'Function' }), + ownerRange: ownerCapture.range, + template, + receiverName, + methodName, + ...(argumentList === null ? {} : { args: javaCallArgumentFacts(argumentList) }), + }; +} + +/** Standalone extractor for focused tests; production reuses scope-query call nodes. */ +export function captureJavaSpringMessageProducerFacts( + rootNode: SyntaxNode, + filePath: string, +): SpringMessageProducerFact[] { + return rootNode + .descendantsOfType('method_invocation') + .map((node) => captureJavaSpringMessageProducerFact(node, filePath)) + .filter((fact): fact is SpringMessageProducerFact => fact !== null); +} diff --git a/gitnexus/src/core/ingestion/languages/java/spring-non-http-handlers.ts b/gitnexus/src/core/ingestion/languages/java/spring-non-http-handlers.ts new file mode 100644 index 000000000..a9e7c867e --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/java/spring-non-http-handlers.ts @@ -0,0 +1,50 @@ +import { makeScopeId } from 'gitnexus-shared'; +import { + createSpringNonHttpHandlerMetadataAttacher, + hasSpringNonHttpHandlerRelevantAnnotation, + type SpringNonHttpHandlerFact, +} from '../../frameworks/spring/non-http-handlers.js'; +import { nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; +import { getJavaSpringNonHttpHandlerFacts } from './capture-side-channel.js'; +import { isJavaPackageSiblingVisibilityIncomplete } from './package-siblings.js'; +import { javaSpringAnnotationFacts, type JavaAnnotationSyntaxFact } from './spring-di.js'; + +export type JavaSpringNonHttpHandlerFact = SpringNonHttpHandlerFact; + +/** + * Capture callable syntax while the Java class AST is already in hand. + * + * Annotation arguments are read in a second pass, only for callables that + * already carry a handler annotation, so the destination-bearing arguments + * (`topics`, `queues`, `destination`, `cron`) reach the fact without adding + * structured argument text to every annotation in the repository. Java can + * decide that on the simple name alone; Kotlin runs the same two passes but + * widens the first one with the file's import aliases, because a Kotlin handler + * annotation may be written under a name no list can contain. + */ +export function captureJavaSpringNonHttpHandlerFacts( + classNode: SyntaxNode, + filePath: string, +): JavaSpringNonHttpHandlerFact[] { + const facts: JavaSpringNonHttpHandlerFact[] = []; + const body = classNode.childForFieldName('body'); + if (body === null) return facts; + for (const member of body.namedChildren) { + if (member.type !== 'method_declaration') continue; + if (!hasSpringNonHttpHandlerRelevantAnnotation(javaSpringAnnotationFacts(member))) continue; + const annotations = javaSpringAnnotationFacts(member, { includeArguments: true }); + const ownerRange = nodeToCapture('@spring-non-http-handler.owner', member).range; + facts.push({ + ownerScopeId: makeScopeId({ filePath, range: ownerRange, kind: 'Function' }), + ownerFilePath: filePath, + ownerRange, + annotations, + }); + } + return facts; +} + +export const attachJavaSpringNonHttpHandlerMetadata = createSpringNonHttpHandlerMetadataAttacher({ + getFacts: getJavaSpringNonHttpHandlerFacts, + isPackageVisibilityIncomplete: isJavaPackageSiblingVisibilityIncomplete, +}); diff --git a/gitnexus/src/core/ingestion/languages/javascript/import-target.ts b/gitnexus/src/core/ingestion/languages/javascript/import-target.ts index bfdfe9951..47b8e9fa8 100644 --- a/gitnexus/src/core/ingestion/languages/javascript/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/javascript/import-target.ts @@ -1,72 +1,50 @@ /** * Import-target resolver for JavaScript. * - * Delegates to the TypeScript `resolveTsTarget` standard-strategy resolver - * with `language: SupportedLanguages.JavaScript` so the resolver tries - * `.js` / `.jsx` extensions in addition to (or instead of) `.ts` / `.tsx`. + * Delegates to the TypeScript resolver, which is correct rather than merely + * convenient: `jsconfig.json` is a tsconfig by another name, `package.json` + * governs both languages identically, and Node's algorithm does not branch on + * which of the two wrote the file. The extension list already carries the JS + * family, so a `.js`/`.jsx`/`.mjs`/`.cjs` source resolves the same way. * - * The `TsResolveContext.language` flag already exists in `import-target.ts` - * and the resolver (`resolveImportPath`) already branches on it — this - * adapter just wires the right value in. + * CJS `require()` calls reference the same module-path strings as ESM `import` + * statements, so the resolver handles them uniformly with no CJS-specific + * logic here. * - * CJS `require()` calls reference the same module-path strings as ESM - * `import` statements, so the resolver handles them uniformly without any - * CJS-specific logic here. + * ## What #2953 removed * - * No `tsconfig.json` path-alias support (JavaScript projects don't use - * `tsconfig.json` compilerOptions.paths in general). Projects that DO use - * tsconfig-based aliases alongside JavaScript can still resolve via the - * standard extension-suffix fallback; the alias branch is a no-op when - * `tsconfigPaths` is null. + * This adapter used to reach `resolveImportPath`, whose last step was + * `suffixResolve` — a search for any repo file whose path ends in the + * specifier, retried with each leading segment dropped. The header this + * replaces recorded the symptom without naming it a defect: `import 'app/main'` + * resolving to `node_modules/dep/lib/main.js`, "the first `/main.js` in file + * order". A bare specifier now resolves only through a declared tsconfig + * mapping or a package manifest, and otherwise not at all. */ -import { SupportedLanguages } from 'gitnexus-shared'; -import { resolveTsTarget, type TsResolveContext } from '../typescript/import-target.js'; +import type { NodeWorkspacePackages } from '../../import-resolvers/node-workspace-packages.js'; +import { resolveTsTarget } from '../typescript/import-target.js'; +import type { TsconfigIndex } from '../typescript/tsconfig.js'; -export type JsResolveContext = TsResolveContext; +interface JsResolutionConfig { + readonly tsconfigs?: TsconfigIndex | null; + readonly nodeWorkspacePackages?: NodeWorkspacePackages | null; +} -type PassCache = { - readonly key: ReadonlySet; - readonly allFilePaths: Set; - readonly allFileList: readonly string[]; - readonly normalizedFileList: readonly string[]; - readonly resolveCache: Map; -}; - -/** - * Build a memoized `resolveImportTarget` adapter for JavaScript. - * Caches the derived arrays and per-pass resolve cache across - * `resolveImportTarget` calls within a single workspace pass. - */ +/** Build the JavaScript `resolveImportTarget` adapter. */ export function makeJsResolveImportTarget(): ( targetRaw: string, fromFile: string, allFilePaths: ReadonlySet, resolutionConfig?: unknown, ) => string | readonly string[] | null { - let cached: PassCache | null = null; - - return (targetRaw, fromFile, allFilePaths) => { - if (cached === null || cached.key !== allFilePaths) { - const allFileList = Array.from(allFilePaths); - cached = { - key: allFilePaths, - allFilePaths: new Set(allFilePaths), - allFileList, - normalizedFileList: allFileList.map((f) => f.toLowerCase()), - resolveCache: new Map(), - }; - } - - const ws: JsResolveContext = { + return (targetRaw, fromFile, allFilePaths, resolutionConfig) => { + const cfg = resolutionConfig as JsResolutionConfig | undefined; + return resolveTsTarget(targetRaw, { fromFile, - language: SupportedLanguages.JavaScript, - allFilePaths: cached.allFilePaths, - allFileList: cached.allFileList, - normalizedFileList: cached.normalizedFileList, - resolveCache: cached.resolveCache, - tsconfigPaths: null, - }; - return resolveTsTarget(targetRaw, ws); + allFilePaths, + tsconfigs: cfg?.tsconfigs ?? null, + nodeWorkspacePackages: cfg?.nodeWorkspacePackages ?? null, + }); }; } diff --git a/gitnexus/src/core/ingestion/languages/javascript/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/javascript/scope-resolver.ts index 94967384e..8d74798ee 100644 --- a/gitnexus/src/core/ingestion/languages/javascript/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/javascript/scope-resolver.ts @@ -42,6 +42,8 @@ import { javascriptProvider } from '../typescript.js'; import { jsMergeBindings } from './merge-bindings.js'; import { jsArityCompatibility } from './arity.js'; import { makeJsResolveImportTarget } from './import-target.js'; +import { loadTsconfigIndex } from '../typescript/tsconfig.js'; +import { loadNodeWorkspacePackages } from '../../import-resolvers/node-workspace-packages.js'; const javascriptScopeResolver: ScopeResolver = { // Construction is keyword-prefixed: `new Service(db).doWork()` (#2708). @@ -52,6 +54,15 @@ const javascriptScopeResolver: ScopeResolver = { resolveImportTarget: makeJsResolveImportTarget(), + // JavaScript resolution reads the same declared inputs TypeScript does — + // `jsconfig.json` is a tsconfig by another name, and `package.json` is shared + // outright. Without them a bare specifier used to fall through to suffix + // matching (#2953); now it simply does not resolve. + loadResolutionConfig: async (repoPath: string) => ({ + tsconfigs: await loadTsconfigIndex(repoPath), + nodeWorkspacePackages: await loadNodeWorkspacePackages(repoPath), + }), + // JavaScript LEGB — same tier ordering as TypeScript; no declaration- // merging across type/value/namespace spaces. mergeBindings: (existing, incoming) => [...jsMergeBindings([...existing, ...incoming])], diff --git a/gitnexus/src/core/ingestion/languages/jvm/accessor-synthesis.ts b/gitnexus/src/core/ingestion/languages/jvm/accessor-synthesis.ts new file mode 100644 index 000000000..e1cb89253 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/jvm/accessor-synthesis.ts @@ -0,0 +1,316 @@ +/** + * Shared planning orchestration and emission for synthetic JVM accessors. + * + * Language adapters discover accessor plans. This module owns method-collision + * policy, graph emission, and scope captures without naming any language. + */ +import type Parser from 'tree-sitter'; +import type { Capture, CaptureMatch } from 'gitnexus-shared'; +import { toZeroBasedLine } from '../../utils/line-base.js'; + +export type SyntheticVisibility = 'public' | 'protected' | 'private' | 'package'; +export type MethodNameMatching = 'exact' | 'case-folded'; + +export interface ExistingMethodIndex { + readonly matching: MethodNameMatching; + readonly aritiesByName: Map>; + readonly arityRangesByName: Map>; +} + +export function createExistingMethodIndex(matching: MethodNameMatching): ExistingMethodIndex { + return { matching, aritiesByName: new Map(), arityRangesByName: new Map() }; +} + +function methodKey(index: ExistingMethodIndex, name: string): string { + return index.matching === 'case-folded' ? name.toLowerCase() : name; +} + +export function rememberExistingMethod( + index: ExistingMethodIndex, + name: string, + arity: number, +): void { + const key = methodKey(index, name); + let arities = index.aritiesByName.get(key); + if (!arities) { + arities = new Set(); + index.aritiesByName.set(key, arities); + } + arities.add(arity); +} + +export function rememberExistingMethodRange( + index: ExistingMethodIndex, + name: string, + min: number, + max: number, +): void { + if (min === max) { + rememberExistingMethod(index, name, min); + return; + } + const key = methodKey(index, name); + const ranges = index.arityRangesByName.get(key) ?? []; + ranges.push({ min, max }); + index.arityRangesByName.set(key, ranges); +} + +export function hasExistingMethod( + index: ExistingMethodIndex, + name: string, + arity: number, +): boolean { + const key = methodKey(index, name); + if (index.aritiesByName.get(key)?.has(arity) === true) return true; + return ( + index.arityRangesByName.get(key)?.some((range) => range.min <= arity && arity <= range.max) === + true + ); +} + +export interface SyntheticAccessorSymbol { + filePath: string; + name: string; + nodeId: string; + type: 'Method'; + ownerId: string; + qualifiedName: string; + parameterCount: number; + requiredParameterCount: number; + parameterTypes: string[]; + returnType: string; + visibility: SyntheticVisibility; + isStatic: boolean; + isAbstract: boolean; + isFinal: boolean; +} + +export interface SyntheticAccessorNode { + id: string; + label: 'Method'; + properties: { + name: string; + filePath: string; + startLine: number; + endLine: number; + language: string; + isExported: boolean; + synthetic: string; + visibility: SyntheticVisibility; + isStatic: boolean; + returnType: string; + parameterTypes: string[]; + parameterCount: number; + qualifiedName: string; + }; +} + +export interface SyntheticAccessorRelationship { + id: string; + sourceId: string; + targetId: string; + type: 'HAS_METHOD'; + confidence: number; + reason: string; +} + +export interface SyntheticAccessorResult { + symbols: SyntheticAccessorSymbol[]; + nodes: SyntheticAccessorNode[]; + relationships: SyntheticAccessorRelationship[]; +} + +export interface PlannedJvmAccessor { + kind: 'getter' | 'setter'; + name: string; + returnType: string; + parameterTypes: string[]; + visibility: SyntheticVisibility; + isStatic: boolean; + isAbstract: boolean; + startLine: number; + endLine: number; + declaratorNode: Parser.SyntaxNode; +} + +export interface PlannedJvmAccessorOwner { + node: Parser.SyntaxNode; + name: string; + accessors: readonly PlannedJvmAccessor[]; +} + +interface JvmAccessorSynthesisConfig { + language: string; + synthetic: string; + planOwners(rootNode: Parser.SyntaxNode): readonly PlannedJvmAccessorOwner[]; +} + +export interface JvmAccessorSynthesis { + synthesize( + tree: Parser.Tree, + filePath: string, + classOwnersById: ReadonlyMap, + ): SyntheticAccessorResult; + captures(rootNode: Parser.SyntaxNode): CaptureMatch[]; +} + +export function createJvmAccessorSynthesis( + config: JvmAccessorSynthesisConfig, +): JvmAccessorSynthesis { + return { + synthesize(tree, filePath, classOwnersById) { + const result = emptySyntheticAccessorResult(); + for (const owner of config.planOwners(tree.rootNode)) { + const ownerId = classOwnersById.get(owner.node.id); + if (!ownerId) continue; + emitPlannedAccessors({ + planned: owner.accessors, + filePath, + ownerId, + idPrefix: ownerIdNamePrefix(ownerId, filePath, owner.name), + language: config.language, + synthetic: config.synthetic, + result, + }); + } + return result; + }, + captures(rootNode) { + return capturesForPlannedAccessors(config.planOwners(rootNode)); + }, + }; +} + +function emptySyntheticAccessorResult(): SyntheticAccessorResult { + return { symbols: [], nodes: [], relationships: [] }; +} + +function ownerIdNamePrefix(ownerId: string, filePath: string, fallback: string): string { + const needle = `Class:${filePath}:`; + if (ownerId.startsWith(needle)) return ownerId.slice(needle.length); + const enumNeedle = `Enum:${filePath}:`; + if (ownerId.startsWith(enumNeedle)) return ownerId.slice(enumNeedle.length); + const ifaceNeedle = `Interface:${filePath}:`; + if (ownerId.startsWith(ifaceNeedle)) return ownerId.slice(ifaceNeedle.length); + return fallback; +} + +export function jvmTypeSimpleName(node: Parser.SyntaxNode): string | undefined { + const named = node.childForFieldName('name')?.text; + if (named) return named; + for (const child of node.namedChildren) { + if (child.type === 'type_identifier' || child.type === 'simple_identifier') return child.text; + } + return undefined; +} + +function emitPlannedAccessors(args: { + planned: readonly PlannedJvmAccessor[]; + filePath: string; + ownerId: string; + idPrefix: string; + language: string; + synthetic: string; + result: SyntheticAccessorResult; +}): void { + const emittedIds = new Set(); + for (const acc of args.planned) { + const arity = acc.parameterTypes.length; + const qualifiedName = `${args.idPrefix}.${acc.name}`; + const nodeId = `Method:${args.filePath}:${qualifiedName}#${arity}`; + if (emittedIds.has(nodeId)) continue; + emittedIds.add(nodeId); + args.result.nodes.push({ + id: nodeId, + label: 'Method', + properties: { + name: acc.name, + filePath: args.filePath, + startLine: toZeroBasedLine(acc.startLine), + endLine: toZeroBasedLine(acc.endLine), + language: args.language, + isExported: false, + synthetic: args.synthetic, + visibility: acc.visibility, + isStatic: acc.isStatic, + returnType: acc.returnType, + parameterTypes: acc.parameterTypes, + parameterCount: arity, + qualifiedName, + }, + }); + args.result.symbols.push({ + filePath: args.filePath, + name: acc.name, + nodeId, + type: 'Method', + ownerId: args.ownerId, + qualifiedName, + parameterCount: arity, + requiredParameterCount: arity, + parameterTypes: acc.parameterTypes, + returnType: acc.returnType, + visibility: acc.visibility, + isStatic: acc.isStatic, + isAbstract: acc.isAbstract, + isFinal: false, + }); + args.result.relationships.push({ + id: `HAS_METHOD:${args.ownerId}->${nodeId}`, + sourceId: args.ownerId, + targetId: nodeId, + type: 'HAS_METHOD', + confidence: 1.0, + reason: acc.kind === 'getter' ? `${args.synthetic}-getter` : `${args.synthetic}-setter`, + }); + } +} + +function accessorCapture(name: string, acc: PlannedJvmAccessor, text: string): Capture { + const node = acc.declaratorNode; + const startLine = node.startPosition.row + 1; + const startCol = node.startPosition.column; + const endLine = node.endPosition.row + 1; + const endCol = acc.kind === 'getter' ? node.endPosition.column : startCol; + return { name, range: { startLine, startCol, endLine, endCol }, text }; +} + +function capturesForPlannedAccessors(owners: readonly PlannedJvmAccessorOwner[]): CaptureMatch[] { + const captures: CaptureMatch[] = []; + for (const owner of owners) { + const enclosing = owner.name; + const emitted = new Set(); + for (const acc of owner.accessors) { + const arity = String(acc.parameterTypes.length); + const qualifiedName = `${enclosing}.${acc.name}`; + const identity = `${qualifiedName}#${arity}`; + if (emitted.has(identity)) continue; + emitted.add(identity); + captures.push({ + '@scope.function': accessorCapture('@scope.function', acc, acc.name), + }); + captures.push({ + '@declaration.method': accessorCapture('@declaration.method', acc, acc.name), + '@declaration.name': accessorCapture('@declaration.name', acc, acc.name), + '@declaration.qualified_name': accessorCapture( + '@declaration.qualified_name', + acc, + qualifiedName, + ), + '@declaration.parameter-count': accessorCapture('@declaration.parameter-count', acc, arity), + '@declaration.required-parameter-count': accessorCapture( + '@declaration.required-parameter-count', + acc, + arity, + ), + '@declaration.return-type': accessorCapture( + '@declaration.return-type', + acc, + acc.returnType, + ), + '@declaration.is-synthetic': accessorCapture('@declaration.is-synthetic', acc, 'true'), + }); + } + } + return captures; +} diff --git a/gitnexus/src/core/ingestion/languages/jvm/beanspec.ts b/gitnexus/src/core/ingestion/languages/jvm/beanspec.ts new file mode 100644 index 000000000..0a14209f5 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/jvm/beanspec.ts @@ -0,0 +1,49 @@ +/** + * Language-neutral JVM JavaBeans naming primitives. + * + * Language adapters choose whether to invent/preserve an `is` prefix and + * which single-character capitalization policy their compiler uses. + */ + +export function capitalizeBeanName(s: string): string { + if (s.length === 0) return s; + const first = s.charAt(0); + const upper = first.toUpperCase(); + // Java Character case conversion is one UTF-16 code unit. JavaScript + // full-case conversion may expand one unit (`ß` → `SS`), which would invent + // a method name no JVM compiler emits. + return (upper.length === 1 ? upper : first) + s.slice(1); +} + +/** + * Primitive-boolean / Kotlin `is`-prefix fields whose name already starts with + * `is` plus a non-lowercase character keep that name for the getter and drop + * the `is` prefix for the setter base (`isEnabled` → `isEnabled()` / + * `setEnabled(...)`, `is1` → `is1()` / `set1(...)`). Digits and punctuation + * count as non-lowercase, matching Lombok `!Character.isLowerCase` and kotlinc. + */ +export function booleanIsPrefixBase(fieldName: string, useIsPrefix: boolean): string | null { + if (!useIsPrefix || !fieldName.startsWith('is') || fieldName.length < 3) return null; + const third = fieldName.charAt(2); + return third === third.toUpperCase() ? fieldName.slice(2) : null; +} + +export function jvmGetterName( + fieldName: string, + useIsPrefix: boolean, + capitalize: (name: string) => string = capitalizeBeanName, +): string { + if (booleanIsPrefixBase(fieldName, useIsPrefix) !== null) return fieldName; + if (useIsPrefix) return `is${capitalize(fieldName)}`; + return `get${capitalize(fieldName)}`; +} + +export function jvmSetterName( + fieldName: string, + useIsPrefix: boolean, + capitalize: (name: string) => string = capitalizeBeanName, +): string { + const stripped = booleanIsPrefixBase(fieldName, useIsPrefix); + if (stripped !== null) return `set${stripped}`; + return `set${capitalize(fieldName)}`; +} diff --git a/gitnexus/src/core/ingestion/languages/kotlin.ts b/gitnexus/src/core/ingestion/languages/kotlin.ts index 18d4fd9c1..b04a58427 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin.ts @@ -24,6 +24,10 @@ import type { SyntaxNode } from '../utils/ast-helpers.js'; import { createCallExtractor } from '../call-extractors/generic.js'; import { kotlinCallConfig } from '../call-extractors/configs/jvm.js'; import { createKotlinCfgVisitor } from '../cfg/visitors/kotlin.js'; +import { + getKotlinSpringMessageProducerFacts, + getKotlinSpringNonHttpHandlerFacts, +} from './kotlin/capture-side-channel.js'; import { createFieldExtractor } from '../field-extractors/generic.js'; import { kotlinConfig } from '../field-extractors/configs/jvm.js'; import { createMethodExtractor } from '../method-extractors/generic.js'; @@ -41,6 +45,16 @@ import { kotlinMergeBindings, kotlinReceiverBinding, } from './kotlin/index.js'; +import { synthesizeLombokAccessors } from './kotlin/lombok-synthesizer.js'; +import { + extractKotlinRuntimeSymbolProperties, + kotlinRuntimeSymbolStrategy, +} from './kotlin/spring-actuator.js'; +import { extractKotlinSpringRoutes } from '../route-extractors/kotlin-spring.js'; +import { + extractKotlinModuleConstants, + foldKotlinOperands, +} from '../route-extractors/kotlin-const-resolver.js'; /** Check if a Kotlin function_declaration capture is inside a class_body (i.e., a method). * Kotlin grammar uses function_declaration for both top-level functions and class methods. @@ -174,6 +188,8 @@ export const kotlinProvider = defineLanguage({ // ── KDoc → description (issue #2270) ── descriptionExtractor: createLeadingDocDescriptionExtractor(), + definitionPropertiesExtractor: extractKotlinRuntimeSymbolProperties, + runtimeSymbolStrategy: kotlinRuntimeSymbolStrategy, labelOverride: (functionNode, defaultLabel) => { if (defaultLabel !== 'Function') return defaultLabel; @@ -202,4 +218,23 @@ export const kotlinProvider = defineLanguage({ mergeBindings: (_scope, bindings) => kotlinMergeBindings(bindings), receiverBinding: kotlinReceiverBinding, arityCompatibility: kotlinArityCompatibility, + synthesizeStructureMembers: synthesizeLombokAccessors, + + // ── Spring decorator routes + composed path constants (#3130) ── + extractDecoratorRoutes: extractKotlinSpringRoutes, + extractModuleConstants: extractKotlinModuleConstants, + foldRoutePathOperands: foldKotlinOperands, + + // Async messaging facts for the `springDestinations` phase. Both stores are + // repopulated on the main thread by `applyKotlinCaptureSideChannel`, so this + // answers for cache hits and misses alike. + getSpringMessagingFacts: (filePath) => ({ + handlers: getKotlinSpringNonHttpHandlerFacts(filePath), + producers: getKotlinSpringMessageProducerFacts(filePath), + }), + // Kotlin string literals interpolate: `"orders-$env"` and `"orders-${env}"` + // are string templates, and a Spring property placeholder has to escape the + // dollar (`"\${app.topic}"`). Destination resolution needs this to keep a + // runtime template out of the address namespace. + interpolatesStringLiterals: true, }); diff --git a/gitnexus/src/core/ingestion/languages/kotlin/capture-side-channel.ts b/gitnexus/src/core/ingestion/languages/kotlin/capture-side-channel.ts index 3c211bcc0..6983e55ec 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/capture-side-channel.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/capture-side-channel.ts @@ -49,14 +49,22 @@ import { } from '../jvm/package-facts.js'; import { getCompanionScopesForFile, markCompanionScope } from './companion-scopes.js'; import { getKotlinPackageFact, setKotlinPackageFact } from './package-facts.js'; +import type { SpringDynamicLookupFact } from '../../frameworks/spring/dynamic-lookups.js'; +import type { SpringMessageProducerFact } from '../../frameworks/spring/message-producers.js'; import type { KotlinSpringAopFact } from './spring-aop.js'; import type { KotlinSpringConditionalFact } from './spring-conditionals.js'; import type { KotlinSpringDiClassFact } from './spring-di.js'; +import type { KotlinSpringNonHttpHandlerFact } from './spring-non-http-handlers.js'; +import type { KotlinSpringConfigConsumerFact } from './spring-config-bindings.js'; const classAnnotations = createClassAnnotationFactStore(); const springAopFacts = new Map(); const springConditionalFacts = new Map(); const springDiFacts = new Map(); +const springDynamicLookupFacts = new Map(); +const springNonHttpHandlerFacts = new Map(); +const springConfigConsumerFacts = new Map(); +const springMessageProducerFacts = new Map(); /** * Plain JSON-serializable snapshot of the per-file Kotlin capture-time @@ -78,6 +86,14 @@ export interface KotlinCaptureSideChannel { readonly springConditionalFacts?: readonly KotlinSpringConditionalFact[]; /** Constructor, property, and method injection syntax captured per class. */ readonly springDiFacts?: readonly KotlinSpringDiClassFact[]; + /** Programmatic Spring bean lookups captured per callable. */ + readonly springDynamicLookupFacts?: readonly SpringDynamicLookupFact[]; + /** Scheduled, event, messaging, and managed-job handler syntax captured per callable. */ + readonly springNonHttpHandlerFacts?: readonly KotlinSpringNonHttpHandlerFact[]; + /** `@Value` / `@ConfigurationProperties` syntax captured per owner. */ + readonly springConfigConsumerFacts?: readonly KotlinSpringConfigConsumerFact[]; + /** Messaging-template publish syntax captured per callable. */ + readonly springMessageProducerFacts?: readonly SpringMessageProducerFact[]; } export function clearKotlinClassAnnotationFacts(): void { @@ -85,6 +101,10 @@ export function clearKotlinClassAnnotationFacts(): void { springAopFacts.clear(); springConditionalFacts.clear(); springDiFacts.clear(); + springDynamicLookupFacts.clear(); + springNonHttpHandlerFacts.clear(); + springConfigConsumerFacts.clear(); + springMessageProducerFacts.clear(); } export function setKotlinSpringAopFacts( @@ -136,6 +156,62 @@ export function getKotlinSpringDiFacts(filePath: string): readonly KotlinSpringD return springDiFacts.get(filePath) ?? []; } +export function setKotlinSpringDynamicLookupFacts( + filePath: string, + facts: readonly SpringDynamicLookupFact[], +): void { + if (facts.length === 0) springDynamicLookupFacts.delete(filePath); + else springDynamicLookupFacts.set(filePath, facts); +} + +export function getKotlinSpringDynamicLookupFacts( + filePath: string, +): readonly SpringDynamicLookupFact[] { + return springDynamicLookupFacts.get(filePath) ?? []; +} + +export function setKotlinSpringNonHttpHandlerFacts( + filePath: string, + facts: readonly KotlinSpringNonHttpHandlerFact[], +): void { + if (facts.length === 0) springNonHttpHandlerFacts.delete(filePath); + else springNonHttpHandlerFacts.set(filePath, facts); +} + +export function getKotlinSpringNonHttpHandlerFacts( + filePath: string, +): readonly KotlinSpringNonHttpHandlerFact[] { + return springNonHttpHandlerFacts.get(filePath) ?? []; +} + +export function setKotlinSpringConfigConsumerFacts( + filePath: string, + facts: readonly KotlinSpringConfigConsumerFact[], +): void { + if (facts.length === 0) springConfigConsumerFacts.delete(filePath); + else springConfigConsumerFacts.set(filePath, facts); +} + +export function getKotlinSpringConfigConsumerFacts( + filePath: string, +): readonly KotlinSpringConfigConsumerFact[] { + return springConfigConsumerFacts.get(filePath) ?? []; +} + +export function setKotlinSpringMessageProducerFacts( + filePath: string, + facts: readonly SpringMessageProducerFact[], +): void { + if (facts.length === 0) springMessageProducerFacts.delete(filePath); + else springMessageProducerFacts.set(filePath, facts); +} + +export function getKotlinSpringMessageProducerFacts( + filePath: string, +): readonly SpringMessageProducerFact[] { + return springMessageProducerFacts.get(filePath) ?? []; +} + /** * `LanguageProvider.collectCaptureSideChannel` implementation for Kotlin. * Returns `undefined` when this file recorded no side-channel state at all, so @@ -149,6 +225,10 @@ export function collectKotlinCaptureSideChannel( const aopFacts = springAopFacts.get(filePath) ?? []; const conditionFacts = springConditionalFacts.get(filePath) ?? []; const diFacts = springDiFacts.get(filePath) ?? []; + const dynamicLookupFacts = springDynamicLookupFacts.get(filePath) ?? []; + const nonHttpHandlerFacts = springNonHttpHandlerFacts.get(filePath) ?? []; + const configConsumerFacts = springConfigConsumerFacts.get(filePath) ?? []; + const messageProducerFacts = springMessageProducerFacts.get(filePath) ?? []; const packageFact = getKotlinPackageFact(filePath); if ( companionScopes.length === 0 && @@ -156,6 +236,10 @@ export function collectKotlinCaptureSideChannel( aopFacts.length === 0 && conditionFacts.length === 0 && diFacts.length === 0 && + dynamicLookupFacts.length === 0 && + nonHttpHandlerFacts.length === 0 && + configConsumerFacts.length === 0 && + messageProducerFacts.length === 0 && packageFact === undefined ) { return undefined; @@ -168,6 +252,12 @@ export function collectKotlinCaptureSideChannel( ...(aopFacts.length > 0 ? { springAopFacts: aopFacts } : {}), ...(conditionFacts.length > 0 ? { springConditionalFacts: conditionFacts } : {}), ...(diFacts.length > 0 ? { springDiFacts: diFacts } : {}), + ...(dynamicLookupFacts.length > 0 ? { springDynamicLookupFacts: dynamicLookupFacts } : {}), + ...(nonHttpHandlerFacts.length > 0 ? { springNonHttpHandlerFacts: nonHttpHandlerFacts } : {}), + ...(configConsumerFacts.length > 0 ? { springConfigConsumerFacts: configConsumerFacts } : {}), + ...(messageProducerFacts.length > 0 + ? { springMessageProducerFacts: messageProducerFacts } + : {}), }; } @@ -193,6 +283,10 @@ export function applyKotlinCaptureSideChannel(parsed: ParsedFile): void { setKotlinSpringAopFacts(parsed.filePath, []); setKotlinSpringConditionalFacts(parsed.filePath, []); setKotlinSpringDiFacts(parsed.filePath, []); + setKotlinSpringDynamicLookupFacts(parsed.filePath, []); + setKotlinSpringNonHttpHandlerFacts(parsed.filePath, []); + setKotlinSpringConfigConsumerFacts(parsed.filePath, []); + setKotlinSpringMessageProducerFacts(parsed.filePath, []); setKotlinPackageFact(parsed.filePath, UNKNOWN_JVM_PACKAGE_FACT); return; } @@ -212,6 +306,22 @@ export function applyKotlinCaptureSideChannel(parsed: ParsedFile): void { parsed.filePath, Array.isArray(data.springDiFacts) ? data.springDiFacts : [], ); + setKotlinSpringDynamicLookupFacts( + parsed.filePath, + Array.isArray(data.springDynamicLookupFacts) ? data.springDynamicLookupFacts : [], + ); + setKotlinSpringNonHttpHandlerFacts( + parsed.filePath, + Array.isArray(data.springNonHttpHandlerFacts) ? data.springNonHttpHandlerFacts : [], + ); + setKotlinSpringConfigConsumerFacts( + parsed.filePath, + Array.isArray(data.springConfigConsumerFacts) ? data.springConfigConsumerFacts : [], + ); + setKotlinSpringMessageProducerFacts( + parsed.filePath, + Array.isArray(data.springMessageProducerFacts) ? data.springMessageProducerFacts : [], + ); setKotlinPackageFact( parsed.filePath, isJvmPackageFact(data.packageFact) ? data.packageFact : UNKNOWN_JVM_PACKAGE_FACT, diff --git a/gitnexus/src/core/ingestion/languages/kotlin/captures.ts b/gitnexus/src/core/ingestion/languages/kotlin/captures.ts index 4e063c3cf..a24073324 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/captures.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/captures.ts @@ -23,16 +23,30 @@ import { setKotlinSpringAopFacts, setKotlinSpringConditionalFacts, setKotlinSpringDiFacts, + setKotlinSpringDynamicLookupFacts, + setKotlinSpringMessageProducerFacts, + setKotlinSpringNonHttpHandlerFacts, + setKotlinSpringConfigConsumerFacts, } from './capture-side-channel.js'; import { captureKotlinPackageFact } from './package-facts.js'; import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js'; +import { synthesizeLombokAccessorCaptures } from './lombok-synthesizer.js'; import { captureKotlinSpringDiClassFact, type KotlinSpringDiClassFact } from './spring-di.js'; +import { captureKotlinSpringConfigConsumerFacts } from './spring-config-bindings.js'; +import type { SpringDynamicLookupFact } from '../../frameworks/spring/dynamic-lookups.js'; +import { captureKotlinSpringDynamicLookupFact } from './spring-dynamic-lookup.js'; +import type { SpringMessageProducerFact } from '../../frameworks/spring/message-producers.js'; +import { captureKotlinSpringMessageProducerFact } from './spring-message-producers.js'; import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js'; import { captureKotlinSpringAopFacts, type KotlinSpringAopFact } from './spring-aop.js'; import { captureKotlinSpringConditionalFacts, type KotlinSpringConditionalFact, } from './spring-conditionals.js'; +import { + captureKotlinSpringNonHttpHandlerFacts, + type KotlinSpringNonHttpHandlerFact, +} from './spring-non-http-handlers.js'; const FUNCTION_DECL_TAGS = ['@declaration.function'] as const; @@ -99,7 +113,12 @@ export function emitKotlinScopeCaptures( const springAopTypeNodeIds = new Set(); const springConditionalFacts: KotlinSpringConditionalFact[] = []; const springDiFacts: KotlinSpringDiClassFact[] = []; + const springNonHttpHandlerFacts: KotlinSpringNonHttpHandlerFact[] = []; + const springNonHttpHandlerTypeNodeIds = new Set(); const springDiClassNodeIds = new Set(); + const springDynamicLookupFacts: SpringDynamicLookupFact[] = []; + const springMessageProducerFacts: SpringMessageProducerFact[] = []; + const springMemberCallNodeIds = new Set(); const returnTypes = collectKotlinReturnTypeTexts(tree.rootNode); out.push(...synthesizeKotlinLocalAssignmentBindings(tree.rootNode, returnTypes)); out.push(...synthesizeKotlinLoopBindings(tree.rootNode, returnTypes)); @@ -123,6 +142,17 @@ export function emitKotlinScopeCaptures( } if (Object.keys(grouped).length === 0) continue; + // One visit per member call node: the same invocation can back several + // query matches, and both Spring call-shape captures must see it once. + const memberCallNode = nodeIfType(groupedNodes['@reference.call.member'], 'call_expression'); + if (memberCallNode !== null && !springMemberCallNodeIds.has(memberCallNode.id)) { + springMemberCallNodeIds.add(memberCallNode.id); + const lookupFact = captureKotlinSpringDynamicLookupFact(memberCallNode, filePath); + if (lookupFact !== null) springDynamicLookupFacts.push(lookupFact); + const producerFact = captureKotlinSpringMessageProducerFact(memberCallNode, filePath); + if (producerFact !== null) springMessageProducerFacts.push(producerFact); + } + // tree-sitter-kotlin represents both classes and interfaces with // `class_declaration`; `object_declaration` is the separate object form. const springAopTypeNode = [ @@ -130,9 +160,17 @@ export function emitKotlinScopeCaptures( nodeIfType(groupedNodes['@scope.class'], 'object_declaration'), nodeIfType(groupedNodes['@scope.class'], 'companion_object'), ].find((node): node is SyntaxNode => node !== null); - if (springAopTypeNode !== undefined && !springAopTypeNodeIds.has(springAopTypeNode.id)) { - springAopTypeNodeIds.add(springAopTypeNode.id); - springAopFacts.push(...captureKotlinSpringAopFacts(springAopTypeNode, filePath)); + if (springAopTypeNode !== undefined) { + if (!springAopTypeNodeIds.has(springAopTypeNode.id)) { + springAopTypeNodeIds.add(springAopTypeNode.id); + springAopFacts.push(...captureKotlinSpringAopFacts(springAopTypeNode, filePath)); + } + if (!springNonHttpHandlerTypeNodeIds.has(springAopTypeNode.id)) { + springNonHttpHandlerTypeNodeIds.add(springAopTypeNode.id); + springNonHttpHandlerFacts.push( + ...captureKotlinSpringNonHttpHandlerFacts(springAopTypeNode, filePath), + ); + } } const springDiClassNode = nodeIfType(groupedNodes['@scope.class'], 'class_declaration'); @@ -342,6 +380,14 @@ export function emitKotlinScopeCaptures( setKotlinSpringAopFacts(filePath, springAopFacts); setKotlinSpringConditionalFacts(filePath, springConditionalFacts); setKotlinSpringDiFacts(filePath, springDiFacts); + setKotlinSpringDynamicLookupFacts(filePath, springDynamicLookupFacts); + setKotlinSpringNonHttpHandlerFacts(filePath, springNonHttpHandlerFacts); + setKotlinSpringConfigConsumerFacts( + filePath, + captureKotlinSpringConfigConsumerFacts(tree.rootNode, filePath), + ); + setKotlinSpringMessageProducerFacts(filePath, springMessageProducerFacts); + out.push(...synthesizeLombokAccessorCaptures(tree.rootNode)); out.push(...synthesizeCallableFlowCaptures(tree.rootNode, KOTLIN_CALLABLE_CAPTURE_OPTIONS)); return out; } diff --git a/gitnexus/src/core/ingestion/languages/kotlin/import-target.ts b/gitnexus/src/core/ingestion/languages/kotlin/import-target.ts index 8ad3c82ee..7fb8b7410 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/import-target.ts @@ -1,16 +1,45 @@ -import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared'; -import { KOTLIN_EXTENSIONS } from '../../import-resolvers/jvm.js'; -import { recordKotlinFileIndexBuild } from './index-stats.js'; +/** + * Adapter from `(ParsedImport, WorkspaceIndex)` to Kotlin declared-package + * resolution. Package facts and module bindings are already present on the + * parsed workspace, so this performs no source I/O and no path inference. + */ + +import type { ParsedFile, ParsedImport, WorkspaceIndex } from 'gitnexus-shared'; +import { perFileSet } from '../../import-resolvers/per-file-set.js'; +import { getKotlinPackageFact } from './package-facts.js'; +import { + buildKotlinPackageIndex, + resolveKotlinModule, + type KotlinPackageIndex, +} from './module-resolution.js'; export interface KotlinResolveContext { readonly fromFile: string; readonly allFilePaths: ReadonlySet; + /** Stable parsed-workspace identity supplied by the resolution pass. */ + readonly parsedFiles?: readonly ParsedFile[]; } +const getKotlinPackageIndex = perFileSet( + (parsedFiles: readonly ParsedFile[]): KotlinPackageIndex => + buildKotlinPackageIndex(parsedFiles, getKotlinPackageFact), +); + export function resolveKotlinImportTarget( parsedImport: ParsedImport, workspaceIndex: WorkspaceIndex, ): string | readonly string[] | null { + const ctx = narrowContext(workspaceIndex); + if (ctx === null || parsedImport.kind === 'dynamic-unresolved') return null; + if (parsedImport.targetRaw === null || parsedImport.targetRaw === '') return null; + + const parsedFiles = ctx.parsedFiles; + if (parsedFiles === undefined || parsedFiles.length === 0) return null; + + return resolveKotlinModule(parsedImport.targetRaw, getKotlinPackageIndex(parsedFiles)); +} + +function narrowContext(workspaceIndex: WorkspaceIndex): KotlinResolveContext | null { const ctx = workspaceIndex as KotlinResolveContext | undefined; if ( ctx === undefined || @@ -19,253 +48,5 @@ export function resolveKotlinImportTarget( ) { return null; } - if (parsedImport.kind === 'dynamic-unresolved') return null; - if (parsedImport.targetRaw === null || parsedImport.targetRaw === '') return null; - - const target = parsedImport.targetRaw.endsWith('.*') - ? parsedImport.targetRaw.slice(0, -2) - : parsedImport.targetRaw; - const pathLike = target.replace(/\./g, '/'); - - // Resolution tiers, most-specific first: - // 1. The full `pathLike` matches a `.kt`/`.kts` file directly - // (`import util.User` → `util/User.kt`). - // 2. Stripped (last-segment removed) `pathLike` matches a file - // directly (`import util.OneArg.writeAudit` → `util/OneArg.kt`, - // a class-or-object holding `writeAudit`). - // 3. Stripped `pathLike` matches a *package directory* — fan out to - // every `.kt`/`.kts` file inside it (`import models.getRepo` → - // `[models/User.kt, models/Repo.kt]`). The finalize pass walks - // each candidate and picks the one whose `localDefs` actually - // export the imported name (#1759). - // 4. Progressive prefix strip for deeper namespace aliases that - // don't map 1:1 to directories. - const index = getKotlinFileIndex(ctx.allFilePaths); - const direct = findKotlinFile(index, pathLike); - if (direct !== null) return direct; - - // Only tiers 2 and 3 need the stripped path, and tier 1 answers most - // imports, so it is computed here rather than above. `lastIndexOf`/`slice` - // rather than `split`/`slice`/`join`: same result for every input, two - // allocations fewer per import. The `li < 0` guard is load-bearing — - // `'a'.slice(0, -1)` is `''`, which is what the split form yields for a - // single-segment path, but only by accident of `[].join('/')`. - const li = pathLike.lastIndexOf('/'); - const stripped = li < 0 ? '' : pathLike.slice(0, li); - return ( - findKotlinExactOrSuffix(index, stripped) ?? - findKotlinPackageFiles(index, stripped) ?? - findByProgressivePrefixStrip(index, pathLike) - ); + return ctx; } - -function findKotlinFile(index: KotlinFileIndex, pathLike: string): string | null { - return findKotlinExactOrSuffix(index, pathLike) ?? findKotlinDirectoryChild(index, pathLike); -} - -/** Exact (`file === pathLike+ext`) or suffix (`file ends with /pathLike+ext`) - * match — does NOT fall back to picking an arbitrary file inside a - * `pathLike/` directory. Used by the stripped-path tier in - * `resolveKotlinImportTarget` so a package import like `models.getRepo` - * delegates to `findKotlinPackageFiles` (multi-file fan-out) instead of - * silently committing to the first directory child. - * - * An exact match anywhere in the workspace beats a suffix match anywhere, - * which is why the two are separate maps rather than one lookup: the old scan - * returned on the first exact hit but only remembered the first suffix hit, - * so an exact match found late still won. */ -function findKotlinExactOrSuffix(index: KotlinFileIndex, pathLike: string): string | null { - if (pathLike === '') return null; - return index.exactByStem.get(pathLike) ?? index.suffixByStem.get(pathLike) ?? null; -} - -/** First directory child of `pathLike/` — preserves the legacy single- - * file fallback for cases where `pathLike` itself is an unqualified - * package reference (rare in real Kotlin code; some fixtures rely on - * it). Multi-file package fan-out goes through - * `findKotlinPackageFiles` instead. */ -function findKotlinDirectoryChild(index: KotlinFileIndex, pathLike: string): string | null { - if (pathLike === '') return null; - const children = index.dirChildren.get(pathLike); - // "First" is first in `allFilePaths` iteration order, which the index - // preserves by appending as it walks the set — the same file the scan - // used to return. - return children === undefined ? null : (children[0] ?? null); -} - -/** - * Return every `.kt`/`.kts` file inside the package directory `dirPath` - * (e.g. `models` → `['models/User.kt', 'models/Repo.kt']`). Used as a - * fallback when an import like `models.getRepo` does not resolve to a - * file named after the symbol — in Kotlin the symbol can live in any - * file inside the package directory. The finalize pass walks each - * candidate and picks the one whose `localDefs` actually export the - * imported name (#1759). - */ -function findKotlinPackageFiles(index: KotlinFileIndex, dirPath: string): readonly string[] | null { - if (dirPath === '') return null; - return index.dirChildren.get(dirPath) ?? null; -} - -function findByProgressivePrefixStrip(index: KotlinFileIndex, pathLike: string): string | null { - const segments = pathLike.split('/').filter(Boolean); - for (let skip = 1; skip < segments.length; skip++) { - const found = findKotlinFile(index, segments.slice(skip).join('/')); - if (found !== null) return found; - } - return null; -} - -/** - * Per-file-set lookup tables for Kotlin import resolution, memoized on the - * `allFilePaths` Set object (the same Set is passed for every import in a run, - * so the index is built once and reused). - * - * WHY: every tier of `resolveKotlinImportTarget` used to walk the whole - * workspace — `for (const raw of allFilePaths)` with a `replace(/\\/g, '/')` - * and several string scans per entry — and the tiers are tried in cascade, so a - * single unresolved import cost two to four full passes. Across a repository - * with tens of thousands of Kotlin files that is `O(imports × files)` — on the - * order of 10^10 string operations on one thread, which presents as `analyze` - * sitting at exactly 1.00 core with a flat heap and no output for hours (every - * allocation is a short-lived string, so nothing accumulates to hint at - * progress). Small repositories hide it completely: at a few hundred files each - * pass is free. - * - * The maps below make each tier O(1), so resolution cost becomes O(files) once - * plus O(1) per import. - * - * - `exactByStem`: path minus its `.kt`/`.kts` extension -> raw path, for the - * `file === pathLike+ext` tier. - * - `suffixByStem`: every component-suffix of that stem -> raw path, for the - * `file ends with /pathLike+ext` tier. Keyed per suffix rather than per - * basename so a multi-segment import (`util/OneArg`) hits one bucket instead - * of filtering a basename bucket. The basename-bucket form Python uses was - * built and measured against this one during review: byte-identical output, - * ~66% less memory, and 7.3x slower per query on a repeated-basename corpus - * — enough to fail this resolver's own scaling budget at ~2.0. The memory - * the per-suffix keying costs is small in absolute terms (~60 MiB at 100k - * Kotlin files at depth 8), so it is not a trade worth revisiting. - * - `dirChildren`: package directory -> its direct `.kt`/`.kts` children, in - * set-iteration order, serving both the fan-out tier and the - * first-child fallback. - * - * Both stem maps keep the FIRST path inserted for a key, because the scans they - * replace returned the first match in set-iteration order. - * - * The shared `buildSuffixIndex` (`import-resolvers/utils.ts`, used by C#, Ruby, - * Vue and TypeScript) is deliberately NOT reused — the same call Python - * documents at `python/import-target.ts`. Run side by side against this - * resolver, four probes out of five diverge: - * - * - `['deep/util/User.kt', 'util/User.kt']` for `util.User` — it conflates - * exact and proper-suffix matches in one map, so the deep path wins where - * the scan returned the exact one; - * - `['deep/util/User.kt', 'util/User.kts']` for `util.User` — its keys carry - * the extension, so a `.kt` SUFFIX beats a `.kts` EXACT; - * - `['data/src/…/data/Repo.kt']` for `data.getRepo` — it indexes every - * directory suffix with no first-occurrence rule, so it fans out where the - * scan returned null; - * - `['models/A.kts', 'models/B.kt']` for `models.getThing` — it splits the - * package into `:kt` and `:kts` buckets instead of returning both in set - * order. - * - * Each divergence is an edge that would move in every Kotlin repository, so - * consolidating the two is a behaviour change, not a cleanup. - */ -interface KotlinFileIndex { - readonly exactByStem: Map; - readonly suffixByStem: Map; - /** Buckets are frozen once the build loop finishes — see `getKotlinFileIndex`. */ - readonly dirChildren: Map; -} - -const KOTLIN_FILE_INDEX_CACHE = new WeakMap, KotlinFileIndex>(); - -function getKotlinFileIndex(allFilePaths: ReadonlySet): KotlinFileIndex { - const cached = KOTLIN_FILE_INDEX_CACHE.get(allFilePaths); - if (cached !== undefined) return cached; - // Cache miss: materialize a fresh index. Counted so a test can assert this - // happens once per run, not once per import. - recordKotlinFileIndexBuild(); - - const exactByStem = new Map(); - const suffixByStem = new Map(); - const dirChildren: MutableDirChildren = new Map(); - - for (const raw of allFilePaths) { - const norm = raw.replace(/\\/g, '/'); - const ext = KOTLIN_EXTENSIONS.find((e) => norm.endsWith(e)); - // Kotlin resolution only ever queries `.kt`/`.kts` paths, exactly as the - // scans did before skipping everything else first. - if (ext === undefined) continue; - - const stem = norm.slice(0, norm.length - ext.length); - if (!exactByStem.has(stem)) exactByStem.set(stem, raw); - // Component-suffixes of the stem: one per '/' in it. `a/b/User` yields - // `b/User` and `User`, matching `norm.endsWith('/' + key + ext)`. - for (let i = 0; i < stem.length; i++) { - if (stem[i] !== '/') continue; - const suffix = stem.slice(i + 1); - if (!suffixByStem.has(suffix)) suffixByStem.set(suffix, raw); - } - - const lastSlash = norm.lastIndexOf('/'); - if (lastSlash < 0) continue; // repo-root file has no package directory - const dir = norm.slice(0, lastSlash); - - // The file's own directory always qualifies: the old scan's `atRoot` branch - // matched `norm.startsWith(dir + '/')` and found no '/' after it. - addChild(dirChildren, dir, raw); - - // A component-suffix of the directory also qualifies — but only under the - // rule the scan actually implemented, which is narrower than "the parent - // directory is named `s`": - // - // - `atRoot` was tested FIRST, so if the path *starts* with `s + '/'` the - // scan used index 0 and the remainder still contained '/', i.e. no - // match — even when a later directory is also named `s`. - // - otherwise it used `indexOf`, the FIRST occurrence of `/s/`. A path - // like `data/src/main/kotlin/com/example/data/Repo.kt` therefore does - // NOT count as a child of `data`: the first `/data/` is not the parent, - // and the scan never looked for a second one. - // - // Preserving that exactly keeps this a pure performance change. It is - // arguably a bug — the file IS a direct child of a `data` directory — but - // fixing it here would silently move edges in every Kotlin repository, - // which belongs in its own change with its own fixtures. - for (let i = 0; i < dir.length; i++) { - if (dir[i] !== '/') continue; - const suffix = dir.slice(i + 1); - if (norm.startsWith(`${suffix}/`)) continue; - if (norm.indexOf(`/${suffix}/`) === dir.length - suffix.length - 1) { - addChild(dirChildren, suffix, raw); - } - } - } - - // `findKotlinPackageFiles` hands a bucket straight out of the index — the - // same array `findKotlinDirectoryChild` reads `children[0]` from. The - // `readonly string[]` return type does not survive the caller: the finalize - // pass normalizes with `Array.isArray(t) ? t : [t]`, and `isArray`'s - // `arg is any[]` predicate widens the true branch, so `tsc --strict` accepts - // a `.sort()` or `.push()` there. A downstream sort would permanently - // reorder the cached bucket and flip the FIRST-child tier's answer for every - // later import in the run. Freezing makes the contract true at runtime, so a - // future mutation is a loud TypeError instead of a silent edge move. - for (const bucket of dirChildren.values()) Object.freeze(bucket); - - const index: KotlinFileIndex = { exactByStem, suffixByStem, dirChildren }; - KOTLIN_FILE_INDEX_CACHE.set(allFilePaths, index); - return index; -} - -function addChild(dirChildren: Map, dir: string, raw: string): void { - const bucket = dirChildren.get(dir); - if (bucket === undefined) dirChildren.set(dir, [raw]); - else bucket.push(raw); -} - -/** Mutable view of the buckets, used only while building — the index exposes - * them as `readonly` and freezes them before it is cached. */ -type MutableDirChildren = Map; diff --git a/gitnexus/src/core/ingestion/languages/kotlin/index-stats.ts b/gitnexus/src/core/ingestion/languages/kotlin/index-stats.ts deleted file mode 100644 index a909101d6..000000000 --- a/gitnexus/src/core/ingestion/languages/kotlin/index-stats.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Build counter for the per-file-set Kotlin import-resolution index - * (`getKotlinFileIndex` in `import-target.ts`). - * - * A "build" is a `WeakMap` cache MISS that materializes a fresh - * `KotlinFileIndex` (O(files)). Mirrors `../python/index-stats.ts`: the counter - * is always live rather than gated behind a profiling env var, because an index - * build happens at most once per resolution run, so the single increment is - * negligible and an unconditional counter avoids env-var load-order fragility - * in tests. - * - * Used by `test/integration/kotlin-import-index-reuse.test.ts` to assert the - * index is reused across imports (built once per run) rather than rebuilt per - * import — the regression guard for the quadratic resolution this replaced. - */ - -let INDEX_BUILDS = 0; - -export function recordKotlinFileIndexBuild(): void { - INDEX_BUILDS++; -} - -export function getKotlinFileIndexBuildCount(): number { - return INDEX_BUILDS; -} - -export function resetKotlinFileIndexBuildCount(): void { - INDEX_BUILDS = 0; -} diff --git a/gitnexus/src/core/ingestion/languages/kotlin/index.ts b/gitnexus/src/core/ingestion/languages/kotlin/index.ts index 206254540..49be8bc87 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/index.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/index.ts @@ -8,6 +8,11 @@ export { getKotlinCaptureCacheStats, resetKotlinCaptureCacheStats } from './cach export { interpretKotlinImport, interpretKotlinTypeBinding } from './interpret.js'; export { kotlinArityCompatibility } from './arity.js'; export { resolveKotlinImportTarget, type KotlinResolveContext } from './import-target.js'; +export { + buildKotlinPackageIndex, + resolveKotlinModule, + type KotlinPackageIndex, +} from './module-resolution.js'; export { kotlinMergeBindings } from './merge-bindings.js'; export { populateKotlinOwners } from './owners.js'; export { diff --git a/gitnexus/src/core/ingestion/languages/kotlin/lombok-synthesizer.ts b/gitnexus/src/core/ingestion/languages/kotlin/lombok-synthesizer.ts new file mode 100644 index 000000000..bb0c3d5b7 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/kotlin/lombok-synthesizer.ts @@ -0,0 +1,512 @@ +/** + * Kotlin accessor synthesizer (same provider-hook role as Java Lombok). + * + * kotlinc emits JavaBeans getters/setters for `val`/`var` properties. Those + * methods are absent from the tree-sitter AST, so Java (and Kotlin) calls + * like `user.getName()` miss CALLS edges. Planning is Kotlin-specific; + * naming and Method emission share `jvm/beanspec` + `jvm/accessor-synthesis`. + * + * ## Supported subset (v1) + * - Class / data class / object / companion / interface `val`/`var` properties + * (interface accessors without a custom body are abstract JVM methods). + * - Primary-constructor `val`/`var` class parameters. + * - Names beginning with `is` + a non-lowercase character keep that getter name; all other + * properties, including `Boolean`, use `get`. + * - Custom `get()`/`set()` bodies still emit their JVM accessor Methods. + * - Explicit `fun getX` / `@JvmField` / `const` skip synthesis. + * - `@JvmName`-renamed accessors are suppressed until custom-name emission lands. + * Unsupported: `@JvmStatic` renaming, file-facade top-level properties. + */ +import type Parser from 'tree-sitter'; +import type { CaptureMatch } from 'gitnexus-shared'; +import { booleanIsPrefixBase, jvmGetterName, jvmSetterName } from '../jvm/beanspec.js'; +import { + createExistingMethodIndex, + createJvmAccessorSynthesis, + hasExistingMethod, + jvmTypeSimpleName, + rememberExistingMethod, + type ExistingMethodIndex, + type PlannedJvmAccessor, + type PlannedJvmAccessorOwner, + type SyntheticAccessorResult, + type SyntheticVisibility, +} from '../jvm/accessor-synthesis.js'; + +const KOTLIN_TYPE_DECLS = new Set(['class_declaration', 'object_declaration', 'companion_object']); + +function capitalizeAscii(name: string): string { + const first = name.charAt(0); + return first >= 'a' && first <= 'z' + ? String.fromCharCode(first.charCodeAt(0) - 32) + name.slice(1) + : name; +} + +export function kotlinGetterName(propertyName: string): string { + return jvmGetterName( + propertyName, + booleanIsPrefixBase(propertyName, true) !== null, + capitalizeAscii, + ); +} + +export function kotlinSetterName(propertyName: string): string { + return jvmSetterName(propertyName, true, capitalizeAscii); +} + +interface KtProperty { + name: string; + type: string; + isVar: boolean; + skipGetter: boolean; + skipSetter: boolean; + getterVisibility: SyntheticVisibility; + setterVisibility: SyntheticVisibility; + startLine: number; + endLine: number; + propertyNode: Parser.SyntaxNode; + declaratorNode: Parser.SyntaxNode; +} + +interface KtClass { + node: Parser.SyntaxNode; + name: string; + isStatic: boolean; + isInterface: boolean; + wasHoisted: boolean; + properties: KtProperty[]; + existingMethods: ExistingMethodIndex; +} + +interface KotlinImportIndex { + byLocalName: Map; + shadowedSimpleNames: Set; +} + +function collectKotlinImports(root: Parser.SyntaxNode): KotlinImportIndex { + const byLocalName = new Map(); + const shadowedSimpleNames = new Set(); + for (const child of root.children) { + if (child.type !== 'class_declaration') continue; + const name = jvmTypeSimpleName(child); + if (name) shadowedSimpleNames.add(name); + } + const importList = root.children.find((child) => child.type === 'import_list'); + for (const child of importList?.children ?? []) { + if (child.type !== 'import_header') continue; + const text = child.text + .replace(/^import\s+/, '') + .replace(/\/\*[\s\S]*?\*\//g, '') + .trim(); + const [pathText, aliasText] = text.split(/\s+as\s+/, 2); + const importPath = pathText?.replace(/\s+/g, ''); + if (!importPath || importPath.endsWith('.*')) continue; + const localName = aliasText?.trim() || importPath.split('.').pop(); + if (localName) byLocalName.set(localName, importPath); + } + return { byLocalName, shadowedSimpleNames }; +} + +function annotationUserTypeText(annotation: Parser.SyntaxNode): string { + const constructor = annotation.namedChildren.find((c) => c.type === 'constructor_invocation'); + const userType = + constructor?.namedChildren.find((c) => c.type === 'user_type') ?? + annotation.namedChildren.find((c) => c.type === 'user_type'); + return userType?.text ?? ''; +} + +function isKotlinJvmAnnotation( + annotation: Parser.SyntaxNode, + name: string, + imports: KotlinImportIndex, +): boolean { + const typeText = annotationUserTypeText(annotation); + const canonical = `kotlin.jvm.${name}`; + if (typeText.includes('.')) return typeText === canonical; + const imported = imports.byLocalName.get(typeText); + if (imported !== undefined) return imported === canonical; + if (imports.shadowedSimpleNames.has(typeText)) return false; + return typeText === name; +} + +function kotlinVisibility(modifiers: Parser.SyntaxNode | undefined): SyntheticVisibility { + if (!modifiers) return 'public'; + for (const child of modifiers.namedChildren) { + if (child.type !== 'visibility_modifier') continue; + if (child.text === 'private') return 'private'; + if (child.text === 'protected') return 'protected'; + if (child.text === 'internal') return 'package'; + } + return 'public'; +} + +function hasJvmField(node: Parser.SyntaxNode, imports: KotlinImportIndex): boolean { + const mods = node.children.find((c) => c.type === 'modifiers'); + return ( + mods?.namedChildren.some( + (child) => child.type === 'annotation' && isKotlinJvmAnnotation(child, 'JvmField', imports), + ) === true + ); +} + +function hasConst(node: Parser.SyntaxNode): boolean { + const mods = node.children.find((c) => c.type === 'modifiers'); + if ( + mods?.namedChildren.some( + (child) => child.type === 'property_modifier' && child.text === 'const', + ) + ) { + return true; + } + return node.namedChildren.some((child) => child.type === 'const'); +} + +function isVarBinding(node: Parser.SyntaxNode): boolean | null { + const kind = node.children.find((c) => c.type === 'binding_pattern_kind'); + const text = kind?.text; + if (text === 'var') return true; + if (text === 'val') return false; + return null; +} + +function inferredInitializerType(node: Parser.SyntaxNode): string | undefined { + switch (node.type) { + case 'string_literal': + case 'line_string_literal': + case 'multi_line_string_literal': + return 'String'; + case 'character_literal': + return 'Char'; + case 'boolean_literal': + case 'true': + case 'false': + return 'Boolean'; + case 'long_literal': + return 'Long'; + case 'unsigned_literal': + return /l$/i.test(node.text) ? 'ULong' : 'UInt'; + case 'integer_literal': + case 'decimal_integer_literal': + case 'hex_integer_literal': + case 'octal_integer_literal': + case 'binary_integer_literal': + return 'Int'; + case 'real_literal': + case 'decimal_floating_point_literal': + return /f$/i.test(node.text) ? 'Float' : 'Double'; + case 'prefix_expression': { + const operand = node.namedChildren.at(-1); + return operand ? inferredInitializerType(operand) : undefined; + } + case 'call_expression': { + const callee = node.namedChildren.find((child) => child.type === 'simple_identifier'); + if (!callee) return undefined; + const first = callee.text.charAt(0); + return first !== '' && first === first.toUpperCase() ? callee.text : undefined; + } + default: + return undefined; + } +} + +function propertyTypeText(node: Parser.SyntaxNode): string { + const declarator = + node.type === 'class_parameter' + ? node + : (node.children.find((c) => c.type === 'variable_declaration') ?? node); + const colon = declarator.children.find((c) => c.type === ':'); + let typeNode = colon?.nextNamedSibling ?? null; + while (typeNode?.type === 'type_modifiers') typeNode = typeNode.nextNamedSibling; + if (typeNode) return typeNode.text; + const initializer = node.namedChildren.find( + (child) => + child.id !== declarator.id && + child.type !== 'binding_pattern_kind' && + child.type !== 'modifiers', + ); + return initializer ? (inferredInitializerType(initializer) ?? 'unknown') : 'unknown'; +} + +function propertyNameNode(node: Parser.SyntaxNode): Parser.SyntaxNode | null { + if (node.type === 'class_parameter') { + return node.children.find((c) => c.type === 'simple_identifier') ?? null; + } + const decl = node.children.find((c) => c.type === 'variable_declaration'); + if (decl) { + return decl.children.find((c) => c.type === 'simple_identifier') ?? null; + } + return node.children.find((c) => c.type === 'simple_identifier') ?? null; +} + +function accessorMetadata( + prop: Parser.SyntaxNode, + propertyVisibility: SyntheticVisibility, + imports: KotlinImportIndex, +): { + getterVisibility: SyntheticVisibility; + setterVisibility: SyntheticVisibility; + skipGetter: boolean; + skipSetter: boolean; +} { + let getter = propertyVisibility; + let setter = propertyVisibility; + let skipGetter = false; + let skipSetter = false; + const propertyModifiers = prop.children.find((c) => c.type === 'modifiers'); + for (const annotation of propertyModifiers?.namedChildren ?? []) { + if ( + annotation.type !== 'annotation' || + !isKotlinJvmAnnotation(annotation, 'JvmName', imports) + ) { + continue; + } + const target = annotation.children.find((c) => c.type === 'use_site_target')?.text; + if (target === 'get:') skipGetter = true; + if (target === 'set:') skipSetter = true; + } + const apply = (node: Parser.SyntaxNode): void => { + const modifiers = node.children.find((c) => c.type === 'modifiers'); + if (!modifiers) return; + if (node.type === 'getter') getter = kotlinVisibility(modifiers); + if (node.type === 'setter') setter = kotlinVisibility(modifiers); + if ( + modifiers.namedChildren.some((annotation) => + isKotlinJvmAnnotation(annotation, 'JvmName', imports), + ) + ) { + if (node.type === 'getter') skipGetter = true; + if (node.type === 'setter') skipSetter = true; + } + }; + for (const child of prop.children) { + if (child.type === 'getter' || child.type === 'setter') apply(child); + } + let sib: Parser.SyntaxNode | null = prop.nextNamedSibling; + while (sib && (sib.type === 'getter' || sib.type === 'setter')) { + apply(sib); + sib = sib.nextNamedSibling; + } + return { + getterVisibility: getter, + setterVisibility: setter, + skipGetter, + skipSetter, + }; +} + +function hasKotlinAccessorBody(prop: Parser.SyntaxNode, kind: 'getter' | 'setter'): boolean { + const hasBody = (node: Parser.SyntaxNode): boolean => + node.type === kind && node.children.some((child) => child.type === 'function_body'); + if (prop.children.some(hasBody)) return true; + let sib: Parser.SyntaxNode | null = prop.nextNamedSibling; + while (sib && (sib.type === 'getter' || sib.type === 'setter')) { + if (hasBody(sib)) return true; + sib = sib.nextNamedSibling; + } + return false; +} + +function functionName(node: Parser.SyntaxNode): string | undefined { + return node.children.find((c) => c.type === 'simple_identifier')?.text; +} + +function functionArity(node: Parser.SyntaxNode): number { + const params = node.children.find((c) => c.type === 'function_value_parameters'); + let arity = + node.childForFieldName('receiver') !== null || + node.namedChildren.some((child) => child.type === 'receiver_type') + ? 1 + : 0; + const modifiers = node.children.find((child) => child.type === 'modifiers'); + if ( + modifiers?.namedChildren.some( + (child) => child.type === 'function_modifier' && child.text === 'suspend', + ) + ) { + arity += 1; + } + for (const child of params?.namedChildren ?? []) { + if (child.type === 'parameter' || child.type === 'parameter_with_optional_type') arity += 1; + } + return arity; +} + +function collectExistingMethods(...bodies: Array): ExistingMethodIndex { + const index = createExistingMethodIndex('exact'); + for (const body of bodies) { + if (!body) continue; + for (const child of body.children) { + if (child.type !== 'function_declaration') continue; + const name = functionName(child); + if (!name) continue; + rememberExistingMethod(index, name, functionArity(child)); + } + } + return index; +} + +function toKtProperty(child: Parser.SyntaxNode, imports: KotlinImportIndex): KtProperty | null { + const isVar = isVarBinding(child); + if (isVar === null) return null; + if (hasJvmField(child, imports) || hasConst(child)) return null; + const nameNode = propertyNameNode(child); + if (!nameNode) return null; + const mods = child.children.find((c) => c.type === 'modifiers'); + const visibility = kotlinVisibility(mods); + const accessor = accessorMetadata(child, visibility, imports); + return { + name: nameNode.text, + type: propertyTypeText(child), + isVar, + skipGetter: accessor.skipGetter, + skipSetter: accessor.skipSetter, + getterVisibility: accessor.getterVisibility, + setterVisibility: accessor.setterVisibility, + startLine: child.startPosition.row + 1, + endLine: child.endPosition.row + 1, + propertyNode: child, + declaratorNode: nameNode, + }; +} + +function collectTypedProperties( + parent: Parser.SyntaxNode | null, + type: 'class_parameter' | 'property_declaration', + imports: KotlinImportIndex, +): KtProperty[] { + if (!parent) return []; + const out: KtProperty[] = []; + for (const child of parent.namedChildren) { + if (child.type !== type) continue; + const prop = toKtProperty(child, imports); + if (prop) out.push(prop); + } + return out; +} + +function findKtClasses(root: Parser.SyntaxNode, imports: KotlinImportIndex): KtClass[] { + const classes: KtClass[] = []; + const graphOwnerNode = (node: Parser.SyntaxNode): Parser.SyntaxNode => { + if (node.type !== 'companion_object') return node; + if (jvmTypeSimpleName(node)) return node; + let current = node.parent; + while (current && !KOTLIN_TYPE_DECLS.has(current.type)) current = current.parent; + return current ?? node; + }; + const walk = (node: Parser.SyntaxNode): void => { + if (KOTLIN_TYPE_DECLS.has(node.type)) { + const ownerNode = graphOwnerNode(node); + const name = jvmTypeSimpleName(ownerNode) ?? ''; + const ctor = node.children.find((c) => c.type === 'primary_constructor') ?? null; + const body = node.children.find((c) => c.type === 'class_body') ?? null; + if (name) { + const properties = [ + ...collectTypedProperties(ctor, 'class_parameter', imports), + ...collectTypedProperties(body, 'property_declaration', imports), + ]; + if (properties.length > 0) { + const ownerBody = + ownerNode.id === node.id + ? null + : (ownerNode.children.find((child) => child.type === 'class_body') ?? null); + classes.push({ + node: ownerNode, + name, + isStatic: node.type === 'companion_object', + isInterface: node.children.some((child) => child.type === 'interface'), + wasHoisted: ownerNode.id !== node.id, + properties, + existingMethods: collectExistingMethods(body, ownerBody), + }); + } + } + if (body) { + for (const child of body.namedChildren) { + if (KOTLIN_TYPE_DECLS.has(child.type)) walk(child); + } + } + return; + } + for (const child of node.namedChildren) walk(child); + }; + walk(root); + return classes; +} + +function planAccessors(cls: KtClass): PlannedJvmAccessor[] { + const planned: PlannedJvmAccessor[] = []; + for (const prop of cls.properties) { + const gName = kotlinGetterName(prop.name); + if (!prop.skipGetter && !hasExistingMethod(cls.existingMethods, gName, 0)) { + planned.push({ + kind: 'getter', + name: gName, + returnType: prop.type, + parameterTypes: [], + visibility: prop.getterVisibility, + isStatic: cls.isStatic, + isAbstract: cls.isInterface && !hasKotlinAccessorBody(prop.propertyNode, 'getter'), + startLine: prop.startLine, + endLine: prop.endLine, + declaratorNode: prop.declaratorNode, + }); + } + if (prop.isVar && !prop.skipSetter) { + const sName = kotlinSetterName(prop.name); + if (!hasExistingMethod(cls.existingMethods, sName, 1)) { + planned.push({ + kind: 'setter', + name: sName, + returnType: 'void', + parameterTypes: [prop.type], + visibility: prop.setterVisibility, + isStatic: cls.isStatic, + isAbstract: cls.isInterface && !hasKotlinAccessorBody(prop.propertyNode, 'setter'), + startLine: prop.startLine, + endLine: prop.endLine, + declaratorNode: prop.declaratorNode, + }); + } + } + } + return planned; +} + +function planKotlinAccessorOwners(rootNode: Parser.SyntaxNode): PlannedJvmAccessorOwner[] { + const owners: PlannedJvmAccessorOwner[] = []; + const imports = collectKotlinImports(rootNode); + for (const cls of findKtClasses(rootNode, imports)) { + const accessors = planAccessors(cls); + const existingIndex = cls.wasHoisted + ? owners.findIndex((owner) => owner.node.id === cls.node.id) + : -1; + const existing = existingIndex >= 0 ? owners[existingIndex] : undefined; + if (existing) { + owners[existingIndex] = { + ...existing, + accessors: [...existing.accessors, ...accessors], + }; + } else { + owners.push({ node: cls.node, name: cls.name, accessors }); + } + } + return owners; +} + +const lombokAccessorSynthesis = createJvmAccessorSynthesis({ + language: 'kotlin', + synthetic: 'kotlin-jvm', + planOwners: planKotlinAccessorOwners, +}); + +export function synthesizeLombokAccessors( + tree: Parser.Tree, + filePath: string, + classOwnersById: ReadonlyMap, +): SyntheticAccessorResult { + return lombokAccessorSynthesis.synthesize(tree, filePath, classOwnersById); +} + +export function synthesizeLombokAccessorCaptures(rootNode: Parser.SyntaxNode): CaptureMatch[] { + return lombokAccessorSynthesis.captures(rootNode); +} diff --git a/gitnexus/src/core/ingestion/languages/kotlin/module-resolution.ts b/gitnexus/src/core/ingestion/languages/kotlin/module-resolution.ts new file mode 100644 index 000000000..e56d538c2 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/kotlin/module-resolution.ts @@ -0,0 +1,136 @@ +/** + * Kotlin import resolution against declared packages and module exports (#2960). + * + * Kotlin source layout is conventional, not semantic: a file may declare any + * package from any directory, and a top-level class, function or property need + * not match the file name. Resolution therefore uses the package fact captured + * during parsing plus the file's module-scope bindings. It never guesses from a + * coincidental path suffix. + */ + +import type { ParsedFile } from 'gitnexus-shared'; +import type { JvmPackageFact } from '../jvm/package-facts.js'; + +export interface KotlinPackageIndex { + /** Declared package -> top-level exported name -> files declaring that name. */ + readonly declarationsByPackage: ReadonlyMap>; + /** Declared package -> every file declaring it, for wildcard imports. */ + readonly filesByPackage: ReadonlyMap; + /** Files whose package header could not be interpreted conservatively. */ + readonly unreadablePackageFiles: number; +} + +const EMPTY_INDEX: KotlinPackageIndex = { + declarationsByPackage: new Map(), + filesByPackage: new Map(), + unreadablePackageFiles: 0, +}; + +export function buildKotlinPackageIndex( + parsedFiles: readonly ParsedFile[], + packageOf: (filePath: string) => JvmPackageFact | undefined, +): KotlinPackageIndex { + if (parsedFiles.length === 0) return EMPTY_INDEX; + + const declarationsByPackage = new Map>(); + const filesByPackage = new Map(); + let unreadablePackageFiles = 0; + + for (const parsed of parsedFiles) { + const fact = packageOf(parsed.filePath); + if (fact === undefined) continue; + if (fact.status !== 'known') { + unreadablePackageFiles++; + continue; + } + + const packageName = fact.packageName; + const packageFiles = filesByPackage.get(packageName); + if (packageFiles === undefined) filesByPackage.set(packageName, [parsed.filePath]); + else packageFiles.push(parsed.filePath); + + const moduleScope = + parsed.scopes.find((scope) => scope.id === parsed.moduleScope && scope.kind === 'Module') ?? + parsed.scopes.find((scope) => scope.kind === 'Module'); + if (moduleScope === undefined) continue; + + let declarations = declarationsByPackage.get(packageName); + if (declarations === undefined) { + declarations = new Map(); + declarationsByPackage.set(packageName, declarations); + } + + for (const [name, refs] of moduleScope.bindings) { + if ( + name === '' || + !refs.some((ref) => ref.origin === 'local' && ref.def.filePath === parsed.filePath) + ) { + continue; + } + const files = declarations.get(name); + if (files === undefined) declarations.set(name, [parsed.filePath]); + else if (!files.includes(parsed.filePath)) files.push(parsed.filePath); + } + } + + return { declarationsByPackage, filesByPackage, unreadablePackageFiles }; +} + +/** Resolve a Kotlin import to the file(s) its declarations name. */ +export function resolveKotlinModule( + targetRaw: string, + index: KotlinPackageIndex, +): string | readonly string[] | null { + if (targetRaw === '') return null; + + if (targetRaw.endsWith('.*')) { + const stem = targetRaw.slice(0, -2); + if (stem === '') return null; + + const packageFiles = index.filesByPackage.get(stem); + if (packageFiles !== undefined) return packageFiles; + + // Kotlin also permits star imports from a class or object. Resolve the + // owning top-level declaration, while keeping an undeclared package null. + return resolveTopLevelDeclaration(stem, index); + } + + return resolveTopLevelDeclaration(targetRaw, index); +} + +function resolveTopLevelDeclaration( + qualifiedName: string, + index: KotlinPackageIndex, +): string | readonly string[] | null { + const parts = qualifiedName.split('.').filter((part) => part !== ''); + if (parts.length === 0) return null; + + // A bare name can refer to the special root package. It is still backed by + // package and binding evidence; no path fallback is involved. + if (parts.length === 1) { + const name = parts[0]; + return name === undefined + ? null + : declarationFiles(index.declarationsByPackage.get('')?.get(name)); + } + + for (let split = parts.length - 1; split >= 1; split--) { + const packageName = parts.slice(0, split).join('.'); + const declarations = index.declarationsByPackage.get(packageName); + if (declarations === undefined) continue; + + const declarationName = parts[split]; + if (declarationName === undefined) continue; + const files = declarations.get(declarationName); + if (files === undefined) continue; + return declarationFiles(files); + } + return null; +} + +function declarationFiles(files: readonly string[] | undefined): string | readonly string[] | null { + if (files === undefined || files.length === 0) return null; + // Kotlin permits overloaded top-level callables across files. Preserve the + // complete candidate set instead of choosing one by parse order. + return files.length === 1 ? (files[0] ?? null) : files; +} diff --git a/gitnexus/src/core/ingestion/languages/kotlin/query.ts b/gitnexus/src/core/ingestion/languages/kotlin/query.ts index f442a2b37..94dadc59e 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/query.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/query.ts @@ -121,7 +121,13 @@ const KOTLIN_SCOPE_QUERY = ` ])) @class-annotation.class ;; Declarations — functions / methods / properties +;; +;; A generic FUNCTION's parameters are read for the same reason a generic type's +;; are (#2912 review): \`fun runAny(v: Validator)\` writes a receiver whose +;; argument is a type VARIABLE, and a pass that cannot tell that from a concrete +;; type prunes every implementor from the call's dispatch fan-out. (function_declaration + (type_parameters)? @declaration.type-parameters (simple_identifier) @declaration.name) @declaration.function ;; Lambda bound to a val/var: val handler = { x: Int -> target(x) } diff --git a/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts index af101af3c..983f181b8 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts @@ -26,6 +26,9 @@ import { attachKotlinSpringAopMetadata } from './spring-aop.js'; import { clearKotlinPackageFacts } from './package-facts.js'; import { attachKotlinSpringDiMetadata } from './spring-di.js'; import { attachKotlinSpringConditionalMetadata } from './spring-conditionals.js'; +import { attachKotlinSpringNonHttpHandlerMetadata } from './spring-non-http-handlers.js'; +import { attachKotlinSpringConfigBindings } from './spring-config-bindings.js'; +import { attachKotlinSpringDynamicLookup } from './spring-dynamic-lookup.js'; /** * Kotlin scope resolver for RFC #909 Ring 3. @@ -83,8 +86,12 @@ export const kotlinScopeResolver: ScopeResolver = { return undefined; }, - resolveImportTarget: (targetRaw, fromFile, allFilePaths) => { - const ws: KotlinResolveContext = { fromFile, allFilePaths }; + resolveImportTarget: (targetRaw, fromFile, allFilePaths, _resolutionConfig, context) => { + const ws: KotlinResolveContext = { + fromFile, + allFilePaths, + parsedFiles: context?.parsedFiles, + }; return resolveKotlinImportTarget( { kind: 'named', localName: '_', importedName: '_', targetRaw }, ws, @@ -142,6 +149,9 @@ export const kotlinScopeResolver: ScopeResolver = { attachKotlinSpringAopMetadata(graph, parsedFiles, nodeLookup, indexes); attachKotlinSpringConditionalMetadata(graph, parsedFiles, nodeLookup, indexes); attachKotlinSpringDiMetadata(graph, parsedFiles, nodeLookup, indexes); + attachKotlinSpringNonHttpHandlerMetadata(graph, parsedFiles, nodeLookup, indexes); + attachKotlinSpringDynamicLookup(graph, parsedFiles, nodeLookup, indexes); + attachKotlinSpringConfigBindings(graph, parsedFiles, nodeLookup, indexes); }, }; diff --git a/gitnexus/src/core/ingestion/languages/kotlin/spring-actuator.ts b/gitnexus/src/core/ingestion/languages/kotlin/spring-actuator.ts new file mode 100644 index 000000000..5acb118b8 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/kotlin/spring-actuator.ts @@ -0,0 +1,229 @@ +import path from 'node:path'; +import type { GraphNode, ParsedImport } from 'gitnexus-shared'; +import type { + DefinitionPropertiesContext, + RuntimeCallableIdentity, + RuntimeSymbolStrategy, +} from '../../language-provider.js'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +const RUNTIME_OWNER_ALIASES = 'runtimeOwnerAliases'; +const RUNTIME_CALLABLE_ALIASES = 'runtimeCallableAliases'; +const KOTLIN_SUSPEND = 'kotlinSuspend'; +const fileFacadeMetadataCache = new WeakMap< + SyntaxNode, + { readonly packageName: string; readonly customFacade: string | undefined } +>(); + +function rootNode(node: SyntaxNode): SyntaxNode { + let current = node; + while (current.parent) current = current.parent; + return current; +} + +function packageName(root: SyntaxNode): string { + const header = root.namedChildren.find((child) => child.type === 'package_header'); + return header?.text.replace(/^package\s+/, '').trim() ?? ''; +} + +function qualify(packageNameValue: string, simpleName: string): string { + return packageNameValue.length === 0 ? simpleName : `${packageNameValue}.${simpleName}`; +} + +function standardFacadeName(filePath: string): string { + const stem = path.basename(filePath).replace(/\.(?:kt|kts)$/i, ''); + return `${stem.charAt(0).toUpperCase()}${stem.slice(1)}Kt`; +} + +function jvmNameIdentifiers( + imports: readonly ParsedImport[], + allowUnqualified: boolean, +): readonly string[] { + const names = new Set(['kotlin.jvm.JvmName']); + if (allowUnqualified) names.add('JvmName'); + for (const parsedImport of imports) { + if (parsedImport.kind !== 'named' && parsedImport.kind !== 'alias') continue; + if (parsedImport.importedName !== 'JvmName') continue; + const target = parsedImport.targetRaw.replace(/\\/g, '/'); + if (target === 'kotlin.jvm' || target === 'kotlin.jvm.JvmName') { + names.add(parsedImport.localName); + } + } + return [...names]; +} + +function annotationJvmName( + source: string, + target = '', + imports: readonly ParsedImport[] = [], + allowUnqualified = true, +): string | undefined { + const names = jvmNameIdentifiers(imports, allowUnqualified); + if (names.length === 0) return undefined; + const escapedTarget = target.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const prefix = target.length === 0 ? '' : `${escapedTarget}:`; + const namePattern = [...names] + .map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join('|'); + return new RegExp(`@${prefix}(?:${namePattern})\\s*\\(\\s*["']([^"']+)["']\\s*\\)`).exec( + source, + )?.[1]; +} + +function fileFacadeMetadata( + root: SyntaxNode, + imports: readonly ParsedImport[], + allowUnqualified: boolean, +): { + readonly packageName: string; + readonly customFacade: string | undefined; +} { + const cached = fileFacadeMetadataCache.get(root); + if (cached !== undefined) return cached; + const metadata = { + packageName: packageName(root), + customFacade: annotationJvmName(root.text, 'file', imports, allowUnqualified), + }; + fileFacadeMetadataCache.set(root, metadata); + return metadata; +} + +function hasEnclosingType(node: SyntaxNode): boolean { + let current = node.parent; + while (current) { + if ( + current.type === 'class_declaration' || + current.type === 'object_declaration' || + current.type === 'companion_object' + ) { + return true; + } + current = current.parent; + } + return false; +} + +/** Transient graph metadata used only by the same analysis run's runtime import. */ +export function extractKotlinRuntimeSymbolProperties( + context: DefinitionPropertiesContext, +): Readonly> | undefined { + const properties: Record = {}; + const source = context.definitionNode.text; + const root = rootNode(context.definitionNode); + const allowUnqualifiedJvmName = !/\bannotation\s+class\s+JvmName\b/.test(root.text); + + if ( + (context.nodeLabel === 'Function' || context.nodeLabel === 'Method') && + context.definitionNode.type === 'function_declaration' + ) { + if (/\bsuspend\b/.test(source.slice(0, source.indexOf('fun') + 3))) { + properties[KOTLIN_SUSPEND] = true; + } + const callableJvmName = annotationJvmName( + source, + '', + context.parsedImports, + allowUnqualifiedJvmName, + ); + if (callableJvmName !== undefined) { + properties[RUNTIME_CALLABLE_ALIASES] = [callableJvmName]; + } + if (!hasEnclosingType(context.definitionNode)) { + const facade = fileFacadeMetadata(root, context.parsedImports, allowUnqualifiedJvmName); + properties[RUNTIME_OWNER_ALIASES] = [ + qualify(facade.packageName, facade.customFacade ?? standardFacadeName(context.filePath)), + ]; + } + } else if (context.nodeLabel === 'Property') { + const getterJvmName = annotationJvmName( + source, + 'get', + context.parsedImports, + allowUnqualifiedJvmName, + ); + if (getterJvmName !== undefined) { + properties[RUNTIME_CALLABLE_ALIASES] = [getterJvmName]; + } + if (!hasEnclosingType(context.definitionNode)) { + const facade = fileFacadeMetadata(root, context.parsedImports, allowUnqualifiedJvmName); + properties[RUNTIME_OWNER_ALIASES] = [ + qualify(facade.packageName, facade.customFacade ?? standardFacadeName(context.filePath)), + ]; + } + } + + return Object.keys(properties).length === 0 ? undefined : properties; +} + +function stringArrayProperty(node: GraphNode, property: string): readonly string[] { + const value = node.properties[property]; + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : []; +} + +function callableNames(node: GraphNode): readonly string[] { + return [String(node.properties.name), ...stringArrayProperty(node, RUNTIME_CALLABLE_ALIASES)]; +} + +function propertyGetterNames(node: GraphNode): readonly string[] { + const name = String(node.properties.name); + const capitalized = `${name.charAt(0).toUpperCase()}${name.slice(1)}`; + return [ + name.startsWith('is') && name.length > 2 && /[A-Z]/.test(name.charAt(2)) + ? name + : `get${capitalized}`, + ...stringArrayProperty(node, RUNTIME_CALLABLE_ALIASES), + ]; +} + +function sourceCallableName(runtimeName: string): string { + return runtimeName.endsWith('$default') ? runtimeName.slice(0, -'$default'.length) : runtimeName; +} + +function matchesKotlinCallable(node: GraphNode, runtime: RuntimeCallableIdentity): boolean { + // Kotlin property declarations and their synthesized JVM accessor Methods + // coexist in the graph. Bind runtime getters to the source Property so the + // synthetic accessor cannot turn an otherwise exact match into ambiguity. + if (node.properties.synthetic === 'kotlin-jvm') return false; + + const runtimeName = sourceCallableName(runtime.name); + if (node.label === 'Property') { + const names = propertyGetterNames(node); + if (!names.includes(runtime.name) && !names.includes(runtimeName)) return false; + } else if (!callableNames(node).includes(runtimeName)) { + return false; + } + + const parameterCount = node.properties.parameterCount; + const descriptorTypes = runtime.descriptorParameterTypes; + if ( + typeof parameterCount !== 'number' || + descriptorTypes === undefined || + runtime.name.endsWith('$default') + ) { + return true; + } + if (parameterCount === descriptorTypes.length) return true; + return ( + node.properties[KOTLIN_SUSPEND] === true && + parameterCount + 1 === descriptorTypes.length && + descriptorTypes.at(-1) === 'kotlin/coroutines/Continuation' + ); +} + +export const kotlinRuntimeSymbolStrategy: RuntimeSymbolStrategy = { + callableOwnerAliases(node, owner) { + const aliases = [...stringArrayProperty(node, RUNTIME_OWNER_ALIASES)]; + const ownerName = owner?.properties.qualifiedName; + if (typeof ownerName === 'string') { + aliases.push(ownerName); + if (node.properties.isStatic === true && !ownerName.endsWith('.Companion')) { + aliases.push(`${ownerName}.Companion`); + } + } + return aliases; + }, + + matchesCallable: matchesKotlinCallable, +}; diff --git a/gitnexus/src/core/ingestion/languages/kotlin/spring-config-bindings.ts b/gitnexus/src/core/ingestion/languages/kotlin/spring-config-bindings.ts new file mode 100644 index 000000000..91aa3f9c0 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/kotlin/spring-config-bindings.ts @@ -0,0 +1,467 @@ +import type { KnowledgeGraph } from '../../../graph/types.js'; +import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js'; +import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; +import { makeScopeId, type ParsedFile, type ScopeId } from 'gitnexus-shared'; +import { + bindSpringConfigConsumers, + type SpringConfigConsumer, +} from '../../frameworks/spring/config-bindings.js'; +import { createSpringAnnotationNameResolver } from '../../frameworks/spring/bean-candidates.js'; +import { + parseSpringAnnotationArguments, + parseStaticStringLiteral, +} from '../../frameworks/spring/annotation-arguments.js'; +import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; +import { nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; +import { getKotlinParser } from './query.js'; +import { getKotlinSpringConfigConsumerFacts } from './capture-side-channel.js'; +import { isKotlinPackageSiblingVisibilityIncomplete } from './package-siblings.js'; + +const VALUE_ANNOTATION = 'org.springframework.beans.factory.annotation.Value'; +const CONFIGURATION_PROPERTIES_ANNOTATION = + 'org.springframework.boot.context.properties.ConfigurationProperties'; + +const VALUE_SIMPLE = 'Value'; +const CONFIGURATION_PROPERTIES_SIMPLE = 'ConfigurationProperties'; +const SKIP_USE_SITES = new Set(['get', 'property', 'file']); +const BIND_USE_SITES = new Set(['field', 'set', 'param']); +const OWNER_TYPES = new Set(['class_declaration', 'object_declaration', 'companion_object']); +const INTERPOLATION_TYPES = new Set([ + 'interpolated_identifier', + 'interpolated_expression', + 'interpolation_expression_start', +]); +const STRING_LITERAL_TYPES = new Set(['string_literal', 'character_literal']); + +export interface KotlinSpringConfigConsumerFact { + readonly consumer: SpringConfigConsumer; + readonly annotationName: string; + readonly classScopeId: ScopeId; +} + +interface KotlinAnnotation { + readonly name: string; + readonly node: SyntaxNode; + readonly useSiteTarget?: string; +} + +interface KotlinImports { + readonly exact: ReadonlyMap; + readonly wildcard: ReadonlySet; + readonly localTypes: ReadonlyMap; +} + +function firstDescendantOfType(node: SyntaxNode, type: string): SyntaxNode | undefined { + const stack = [...node.namedChildren].reverse(); + while (stack.length > 0) { + const current = stack.pop(); + if (current === undefined) continue; + if (current.type === type) return current; + for (let index = current.namedChildren.length - 1; index >= 0; index--) { + const child = current.namedChildren[index]; + if (child !== undefined) stack.push(child); + } + } + return undefined; +} + +function ownerName(declaration: SyntaxNode): string | undefined { + if (declaration.type === 'companion_object') { + const named = declaration.namedChildren.find((child) => child.type === 'type_identifier'); + return named?.text.trim() || 'Companion'; + } + return ( + declaration.namedChildren.find((child) => child.type === 'type_identifier')?.text.trim() ?? + declaration.namedChildren.find((child) => child.type === 'simple_identifier')?.text.trim() + ); +} + +function enclosingOwner(node: SyntaxNode): SyntaxNode | undefined { + let current = node.parent; + while (current !== null) { + if (OWNER_TYPES.has(current.type)) return current; + current = current.parent; + } + return undefined; +} + +function classScopeId(filePath: string, declaration: SyntaxNode): ScopeId { + return makeScopeId({ + filePath, + range: nodeToCapture('@scope.class', declaration).range, + kind: 'Class', + }); +} + +function collectKotlinImports(root: SyntaxNode): KotlinImports { + const exact = new Map(); + const wildcard = new Set(); + const localTypes = new Map(); + + for (const header of root.descendantsOfType('import_header')) { + const text = header.text.replace(/^import\s+/, '').trim(); + const aliasMatch = text.match(/^([\w.]+)\s+as\s+(\w+)\s*$/); + if (aliasMatch !== null) { + exact.set(aliasMatch[2], aliasMatch[1]); + continue; + } + if (text.endsWith('.*')) wildcard.add(text.slice(0, -2)); + else { + const simple = text.slice(text.lastIndexOf('.') + 1); + if (simple.length > 0) exact.set(simple, text); + } + } + + for (const type of ['class_declaration', 'object_declaration']) { + for (const declaration of root.descendantsOfType(type)) { + const name = ownerName(declaration); + if (name) { + const declarations = localTypes.get(name) ?? []; + declarations.push(declaration); + localTypes.set(name, declarations); + } + } + } + return { exact, wildcard, localTypes }; +} + +function annotationFromNode(annotation: SyntaxNode): KotlinAnnotation | null { + const nameNode = + firstDescendantOfType(annotation, 'user_type') ?? + firstDescendantOfType(annotation, 'type_identifier') ?? + firstDescendantOfType(annotation, 'simple_identifier'); + if (nameNode === undefined) return null; + const useSiteTarget = annotation.namedChildren + .find((child) => child.type === 'use_site_target') + ?.text.replace(/:\s*$/, '') + .trim(); + return { + name: nameNode.text.trim(), + node: annotation, + ...(useSiteTarget === undefined || useSiteTarget.length === 0 ? {} : { useSiteTarget }), + }; +} + +function annotationsOn(node: SyntaxNode): KotlinAnnotation[] { + const annotations: KotlinAnnotation[] = []; + for (const child of node.namedChildren) { + if (child.type === 'annotation') { + const fact = annotationFromNode(child); + if (fact !== null) annotations.push(fact); + continue; + } + if (child.type !== 'modifiers' && child.type !== 'parameter_modifiers') continue; + for (const nested of child.namedChildren) { + if (nested.type !== 'annotation') continue; + const fact = annotationFromNode(nested); + if (fact !== null) annotations.push(fact); + } + } + return annotations; +} + +function simpleName(rawName: string): string { + const parts = rawName.split('.'); + return parts[parts.length - 1] ?? rawName; +} + +function importedAs( + imports: KotlinImports, + simple: string, + fqn: string, + wildcardPackage: string, +): boolean { + if (imports.exact.get(simple) === fqn) return true; + // An explicit import wins over a star import in Kotlin, so a conflicting + // binding for the same simple name rules the Spring annotation out even when + // its package is wildcard-imported. + return imports.exact.get(simple) === undefined && imports.wildcard.has(wildcardPackage); +} + +function hasVisibleLocalType( + imports: KotlinImports, + simple: string, + annotation: SyntaxNode, +): boolean { + for (const declaration of imports.localTypes.get(simple) ?? []) { + const declarationOwner = enclosingOwner(declaration); + if (declarationOwner === undefined) return true; + let current: SyntaxNode | null = annotation; + while (current !== null) { + if (current.id === declarationOwner.id) return true; + current = current.parent; + } + } + return false; +} + +const SIMPLE_CONFIG_ANNOTATIONS = [ + { + simple: VALUE_SIMPLE, + kind: 'value', + fqn: VALUE_ANNOTATION, + wildcardPackage: 'org.springframework.beans.factory.annotation', + }, + { + simple: CONFIGURATION_PROPERTIES_SIMPLE, + kind: 'configuration-properties', + fqn: CONFIGURATION_PROPERTIES_ANNOTATION, + wildcardPackage: 'org.springframework.boot.context.properties', + }, +] as const; + +function configAnnotationKind( + annotation: KotlinAnnotation, + imports: KotlinImports, +): 'value' | 'configuration-properties' | null { + const rawName = annotation.name; + if (rawName === VALUE_ANNOTATION) return 'value'; + if (rawName === CONFIGURATION_PROPERTIES_ANNOTATION) return 'configuration-properties'; + const simple = simpleName(rawName); + const aliased = imports.exact.get(simple); + if (aliased === VALUE_ANNOTATION) return 'value'; + if (aliased === CONFIGURATION_PROPERTIES_ANNOTATION) return 'configuration-properties'; + for (const candidate of SIMPLE_CONFIG_ANNOTATIONS) { + if (simple !== candidate.simple) continue; + if (hasVisibleLocalType(imports, simple, annotation.node) && !imports.exact.has(simple)) { + return null; + } + return importedAs(imports, simple, candidate.fqn, candidate.wildcardPackage) + ? candidate.kind + : null; + } + return null; +} + +function hasInterpolation(annotation: SyntaxNode): boolean { + const stack: SyntaxNode[] = [...annotation.namedChildren]; + while (stack.length > 0) { + const current = stack.pop(); + if (current === undefined) continue; + if (INTERPOLATION_TYPES.has(current.type)) return true; + stack.push(...current.namedChildren); + } + return false; +} + +function decodeKotlinStringLiteral(literal: string): string | null { + const raw = literal.startsWith('"""') && literal.endsWith('"""'); + const delimiterLength = raw ? 3 : 1; + if (literal.length < delimiterLength * 2) return null; + const body = literal.slice(delimiterLength, -delimiterLength); + if (!raw && /(? + String.fromCharCode(Number.parseInt(hex, 16)), + ) + .replace(/\\(["'\\$btnfr])/g, (_match, escaped: string) => { + const controls: Record = { + b: '\b', + t: '\t', + n: '\n', + f: '\f', + r: '\r', + $: '$', + }; + return controls[escaped] ?? escaped; + }); +} + +function kotlinStringLiterals(annotation: SyntaxNode): string[] { + const literals: string[] = []; + const stack: SyntaxNode[] = [...annotation.namedChildren]; + while (stack.length > 0) { + const current = stack.pop(); + if (current === undefined) continue; + if (STRING_LITERAL_TYPES.has(current.type)) { + const decoded = decodeKotlinStringLiteral(current.text); + if (decoded !== null) literals.push(decoded); + continue; + } + stack.push(...current.namedChildren); + } + return literals; +} + +function parseValuePlaceholderKeys(annotation: SyntaxNode): string[] { + if (hasInterpolation(annotation)) return []; + const keys = new Set(); + for (const literal of kotlinStringLiterals(annotation)) { + for (const match of literal.matchAll(/\$\{([^{}]+)\}/g)) { + const key = match[1].split(':', 1)[0].trim(); + if (/^[A-Za-z0-9_.-]+$/.test(key)) keys.add(key); + } + } + return [...keys]; +} + +function parseConfigurationPropertiesPrefix(annotation: SyntaxNode): string | null { + if (hasInterpolation(annotation)) return null; + const argumentsList = parseSpringAnnotationArguments(annotation.text); + if (argumentsList !== null) { + const named = argumentsList.filter( + (argument) => argument.name === 'prefix' || argument.name === 'value', + ); + const positional = argumentsList.filter((argument) => argument.name === undefined); + const chosen = named.length === 1 ? named[0] : named.length === 0 ? positional[0] : undefined; + if (chosen !== undefined) { + const decoded = parseStaticStringLiteral(chosen.value); + if (decoded === null) return null; + const prefix = decoded.replace(/^\.+|\.+$/g, ''); + if (/^[A-Za-z0-9_.-]+$/.test(prefix)) return prefix; + return null; + } + if (argumentsList.length > 0) return null; + } + const literals = kotlinStringLiterals(annotation); + if (literals.length !== 1) return null; + const prefix = literals[0].trim().replace(/^\.+|\.+$/g, ''); + return /^[A-Za-z0-9_.-]+$/.test(prefix) ? prefix : null; +} + +function allowedUseSite(useSiteTarget: string | undefined): boolean { + if (useSiteTarget === undefined) return true; + if (SKIP_USE_SITES.has(useSiteTarget)) return false; + return BIND_USE_SITES.has(useSiteTarget); +} + +function hasBindingPattern(parameter: SyntaxNode): boolean { + return parameter.namedChildren.some((child) => child.type === 'binding_pattern_kind'); +} + +function propertyName(node: SyntaxNode): string | undefined { + if (node.type === 'class_parameter') { + return node.namedChildren.find((child) => child.type === 'simple_identifier')?.text.trim(); + } + const variable = node.namedChildren.find((child) => child.type === 'variable_declaration'); + return variable?.namedChildren.find((child) => child.type === 'simple_identifier')?.text.trim(); +} + +function underFileAnnotation(node: SyntaxNode): boolean { + let current: SyntaxNode | null = node; + while (current !== null) { + if (current.type === 'file_annotation') return true; + current = current.parent; + } + return false; +} + +function pushValueFacts( + facts: KotlinSpringConfigConsumerFact[], + member: SyntaxNode, + filePath: string, + imports: KotlinImports, +): void { + if (underFileAnnotation(member)) return; + const owner = enclosingOwner(member); + if (owner === undefined) return; + const fieldName = propertyName(member); + if (fieldName === undefined) return; + for (const annotation of annotationsOn(member)) { + if (!allowedUseSite(annotation.useSiteTarget)) continue; + if (configAnnotationKind(annotation, imports) !== 'value') continue; + const keys = parseValuePlaceholderKeys(annotation.node); + if (keys.length === 0) continue; + facts.push({ + consumer: { + kind: 'value', + fieldName, + line: member.startPosition.row + 1, + keys, + }, + annotationName: annotation.name, + classScopeId: classScopeId(filePath, owner), + }); + } +} + +/** Collect config facts from the Kotlin parser's existing AST (no reparse). */ +export function captureKotlinSpringConfigConsumerFacts( + root: SyntaxNode, + filePath: string, +): KotlinSpringConfigConsumerFact[] { + const imports = collectKotlinImports(root); + const facts: KotlinSpringConfigConsumerFact[] = []; + + for (const property of root.descendantsOfType('property_declaration')) { + pushValueFacts(facts, property, filePath, imports); + } + + for (const parameter of root.descendantsOfType('class_parameter')) { + if (!hasBindingPattern(parameter)) continue; + pushValueFacts(facts, parameter, filePath, imports); + } + + for (const type of ['class_declaration', 'object_declaration']) { + for (const declaration of root.descendantsOfType(type)) { + const className = ownerName(declaration); + if (className === undefined) continue; + for (const annotation of annotationsOn(declaration)) { + if (configAnnotationKind(annotation, imports) !== 'configuration-properties') { + continue; + } + const prefix = parseConfigurationPropertiesPrefix(annotation.node); + if (prefix === null) continue; + facts.push({ + consumer: { + kind: 'configuration-properties', + className, + line: declaration.startPosition.row + 1, + prefix, + }, + annotationName: annotation.name, + classScopeId: classScopeId(filePath, declaration), + }); + } + } + } + return facts; +} + +/** Parse Kotlin consumers for focused unit tests; production reuses the worker AST. */ +export function extractKotlinSpringConfigConsumers(source: string): SpringConfigConsumer[] { + const tree = parseSourceSafe(getKotlinParser(), source); + return captureKotlinSpringConfigConsumerFacts(tree.rootNode, '').map( + (fact) => fact.consumer, + ); +} + +export function extractKotlinSpringConfigConsumerFacts( + source: string, +): KotlinSpringConfigConsumerFact[] { + const tree = parseSourceSafe(getKotlinParser(), source); + return captureKotlinSpringConfigConsumerFacts(tree.rootNode, ''); +} + +/** Kotlin ScopeResolver post-resolution hook for Spring configuration consumers. */ +export function attachKotlinSpringConfigBindings( + graph: KnowledgeGraph, + parsedFiles: readonly ParsedFile[], + _nodeLookup: GraphNodeLookup, + indexes: ScopeResolutionIndexes, +): void { + const resolveAnnotation = createSpringAnnotationNameResolver(indexes); + const recognizedAnnotations = new Set([VALUE_ANNOTATION, CONFIGURATION_PROPERTIES_ANNOTATION]); + const batches: Array<{ filePath: string; consumers: SpringConfigConsumer[] }> = []; + for (const parsed of parsedFiles) { + const consumers: SpringConfigConsumer[] = []; + for (const fact of getKotlinSpringConfigConsumerFacts(parsed.filePath)) { + const classScope = indexes.scopeTree.getScope(fact.classScopeId); + if (classScope === undefined || classScope.kind !== 'Class') continue; + const expectedAnnotation = + fact.consumer.kind === 'value' ? VALUE_ANNOTATION : CONFIGURATION_PROPERTIES_ANNOTATION; + const enclosingScope = fact.consumer.kind === 'value' ? classScope.id : classScope.parent; + const resolved = resolveAnnotation( + fact.annotationName, + parsed, + enclosingScope, + recognizedAnnotations, + isKotlinPackageSiblingVisibilityIncomplete(parsed.filePath), + ); + if (resolved === expectedAnnotation) consumers.push(fact.consumer); + } + if (consumers.length > 0) batches.push({ filePath: parsed.filePath, consumers }); + } + bindSpringConfigConsumers(graph, batches); +} diff --git a/gitnexus/src/core/ingestion/languages/kotlin/spring-di.ts b/gitnexus/src/core/ingestion/languages/kotlin/spring-di.ts index acb38360e..af0ba2cec 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/spring-di.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/spring-di.ts @@ -1,5 +1,9 @@ import { makeScopeId } from 'gitnexus-shared'; import { parseSpringInjectionType } from '../../di-extractors/spring.js'; +import { + normalizeSpringFactText, + type SpringArgumentFact, +} from '../../frameworks/spring/argument-facts.js'; import { createSpringDiMetadataAttacher, hasSpringDiRelevantAnnotation, @@ -13,13 +17,29 @@ import { hasSpringBeanFactorySyntax, type SpringBeanFactoryMethodFact, } from '../../frameworks/spring/bean-factories.js'; -import { nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; +import { hasRecoveredSyntax, nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; import { getKotlinSpringDiFacts } from './capture-side-channel.js'; import { isKotlinPackageSiblingVisibilityIncomplete } from './package-siblings.js'; export interface KotlinAnnotationSyntaxFact extends SpringDiAnnotationFact { readonly useSiteTarget?: string; readonly line: number; + /** Present only for callers that opt in via `kotlinSpringAnnotationFacts`. */ + readonly args?: readonly SpringArgumentFact[]; +} + +/** + * Options for `kotlinSpringAnnotationFacts`. + * + * The STRUCTURED arguments are opt-in because DI captures every annotated + * constructor parameter, property, and function in the repository, and none of + * its consumers reads them. Note what this does and does not save: every fact + * already carries `text`, the annotation's full source, so the argument TEXT + * crosses the worker boundary either way. What the opt-in avoids is a second, + * parsed copy of that same text on facts that would never look at it. + */ +export interface KotlinSpringAnnotationFactOptions { + readonly includeArguments?: boolean; } export type KotlinSpringDependencyFact = SpringDiDependencyFact; @@ -53,36 +73,128 @@ function firstDescendantOfType(node: SyntaxNode, type: string): SyntaxNode | und return undefined; } -function annotationFact(annotation: SyntaxNode): KotlinAnnotationSyntaxFact | null { +const KOTLIN_COMMENT_NODE_TYPES = new Set(['line_comment', 'multiline_comment']); + +/** + * Kotlin writes annotation arguments and call arguments with the same + * `value_arguments` node, so one reader serves `@KafkaListener(topics = [...])` + * and `kafkaTemplate.send(topic, payload)`. + * + * A named argument keeps its key; everything else — positional values, spreads, + * collection literals, and interpolated strings — is kept as raw text, because + * evaluating it would be resolution. + * + * Returns `null` for a list tree-sitter had to recover, and the callers decide + * what that means: a producer call drops the whole fact, since it has no state + * for "published somewhere unreadable", while an annotation reports no + * arguments and collapses into the marker form. Both answers say "nothing here + * to resolve", which is true; a fabricated value would send a consumer + * somewhere real and wrong. + * + * The check lives HERE, not only in the callers. This function is exported and + * already has a caller in another module, so a guard that every future caller + * has to remember is the same fragility this change set exists to remove — + * `null` makes the decision unavoidable at the type level. Per-argument + * re-checks are still pointless: `hasError` propagates from any argument up to + * the list, so a branch behind this one could never fire. + * + * A named argument is identified by the `=` TOKEN, and the two-child shape is + * only a corroborating detail. Today nothing well formed reaches two children + * without an `=`: an annotated positional argument such as + * `@Suppress("UNCHECKED_CAST") "orders"` arrives as ONE `prefix_expression`, not + * as two children, so the token test is currently redundant. It is kept as the + * leading condition anyway, because the failure it prevents is asymmetric — + * dropping it would let any future two-child positional shape be reported under + * an argument key the source never wrote, which is the failure mode this whole + * change set is about. + */ +export function kotlinValueArgumentFacts(valueArguments: SyntaxNode): SpringArgumentFact[] | null { + if (hasRecoveredSyntax(valueArguments)) return null; + const args: SpringArgumentFact[] = []; + for (const argument of valueArguments.namedChildren) { + if (argument.type !== 'value_argument') continue; + const parts = argument.namedChildren.filter( + (child) => !KOTLIN_COMMENT_NODE_TYPES.has(child.type), + ); + const named = argument.children.some((child) => child.type === '='); + const name = parts[0]; + const value = parts[1]; + if (named && parts.length === 2 && name !== undefined && value !== undefined) { + args.push({ name: name.text.trim(), text: normalizeSpringFactText(value.text) }); + continue; + } + args.push({ text: normalizeSpringFactText(argument.text) }); + } + return args; +} + +/** + * Arguments of one annotation, or `undefined` when it was written without an + * argument list (`@Scheduled`); `@Scheduled()` yields `[]` instead. + * + * Only the annotation's FIRST `user_type` / `constructor_invocation` child is + * read, which is the same element `annotationFact` names. That matters for the + * multi-annotation form `@field:[Alpha Beta("x")]`, where naively taking the + * first constructor invocation would hand Beta's arguments to Alpha. + * + * An argument list that did not parse also yields `undefined`, collapsing into + * the marker-annotation case on purpose: both say there is nothing readable to + * resolve, while the recovered tree would offer values nobody wrote. + */ +function kotlinAnnotationArgumentFacts(annotation: SyntaxNode): SpringArgumentFact[] | undefined { + const named = annotation.namedChildren.find( + (child) => child.type === 'user_type' || child.type === 'constructor_invocation', + ); + if (named === undefined || named.type !== 'constructor_invocation') return undefined; + const valueArguments = named.namedChildren.find((child) => child.type === 'value_arguments'); + if (valueArguments === undefined) return undefined; + // `null` here means recovered syntax; an annotation answers that by reporting + // no arguments at all, which is the marker-annotation form. + return kotlinValueArgumentFacts(valueArguments) ?? undefined; +} + +function annotationFact( + annotation: SyntaxNode, + options: KotlinSpringAnnotationFactOptions, +): KotlinAnnotationSyntaxFact | null { const nameNode = firstDescendantOfType(annotation, 'user_type'); if (nameNode === undefined) return null; const useSiteTarget = annotation.namedChildren .find((child) => child.type === 'use_site_target') ?.text.replace(/:\s*$/, '') .trim(); + const args = + options.includeArguments === true ? kotlinAnnotationArgumentFacts(annotation) : undefined; return { name: nameNode.text.trim(), text: annotation.text.trim(), line: annotation.startPosition.row + 1, ...(useSiteTarget === undefined || useSiteTarget.length === 0 ? {} : { useSiteTarget }), + ...(args === undefined ? {} : { args }), }; } -function annotationsFromModifierContainer(node: SyntaxNode): KotlinAnnotationSyntaxFact[] { +function annotationsFromModifierContainer( + node: SyntaxNode, + options: KotlinSpringAnnotationFactOptions = {}, +): KotlinAnnotationSyntaxFact[] { const facts: KotlinAnnotationSyntaxFact[] = []; for (const child of node.namedChildren) { if (child.type !== 'annotation') continue; - const fact = annotationFact(child); + const fact = annotationFact(child, options); if (fact !== null) facts.push(fact); } return facts; } -export function kotlinSpringAnnotationFacts(node: SyntaxNode): KotlinAnnotationSyntaxFact[] { +export function kotlinSpringAnnotationFacts( + node: SyntaxNode, + options: KotlinSpringAnnotationFactOptions = {}, +): KotlinAnnotationSyntaxFact[] { const facts: KotlinAnnotationSyntaxFact[] = []; for (const child of node.namedChildren) { if (child.type !== 'modifiers' && child.type !== 'parameter_modifiers') continue; - facts.push(...annotationsFromModifierContainer(child)); + facts.push(...annotationsFromModifierContainer(child, options)); } return facts; } diff --git a/gitnexus/src/core/ingestion/languages/kotlin/spring-dynamic-lookup.ts b/gitnexus/src/core/ingestion/languages/kotlin/spring-dynamic-lookup.ts new file mode 100644 index 000000000..184048e07 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/kotlin/spring-dynamic-lookup.ts @@ -0,0 +1,90 @@ +import { makeScopeId } from 'gitnexus-shared'; +import { + createSpringDynamicLookupMetadataAttacher, + springDynamicLookupCardinality, + type SpringDynamicLookupFact, +} from '../../frameworks/spring/dynamic-lookups.js'; +import { + findAncestorBeforeBoundary, + nodeToCapture, + type SyntaxNode, +} from '../../utils/ast-helpers.js'; +import { getKotlinSpringDynamicLookupFacts } from './capture-side-channel.js'; + +// Kotlin emits graph callables for functions and secondary constructors. +// `init {}` / primary-constructor bodies have no independent callable node, so +// attributing their lookups to the enclosing Class would violate graph semantics. +const CALLABLE_NODE_TYPES = new Set(['function_declaration', 'secondary_constructor']); +const NO_CALLABLE_BOUNDARIES = new Set(); +const KOTLIN_CLASS_LITERAL = + /^([A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*)::class(?:\.java)?$/; + +function navigationParts(node: SyntaxNode): { receiverName: string; methodName: string } | null { + if (node.type !== 'navigation_expression') return null; + const text = node.text.trim(); + const separator = text.lastIndexOf('.'); + if (separator <= 0 || separator === text.length - 1) return null; + return { + receiverName: text.slice(0, separator), + methodName: text.slice(separator + 1), + }; +} + +function singleClassLiteralArgument(node: SyntaxNode): string | null { + const suffix = node.namedChildren.find((child) => child.type === 'call_suffix'); + const argumentsNode = suffix?.namedChildren.find((child) => child.type === 'value_arguments'); + if (argumentsNode === undefined) return null; + const argumentsWithoutComments = argumentsNode.namedChildren.filter( + (child) => child.type !== 'line_comment' && child.type !== 'multiline_comment', + ); + if (argumentsWithoutComments.length !== 1) return null; + const value = argumentsWithoutComments[0]; + if (value?.type !== 'value_argument' || value.namedChildCount !== 1) return null; + return value.namedChild(0)?.text.trim().match(KOTLIN_CLASS_LITERAL)?.[1] ?? null; +} + +/** Capture real Kotlin calls using `Type::class` or `Type::class.java`. */ +export function captureKotlinSpringDynamicLookupFact( + node: SyntaxNode, + filePath: string, +): SpringDynamicLookupFact | null { + if (node.type !== 'call_expression') return null; + const callee = node.namedChildren.find((child) => child.type === 'navigation_expression'); + if (callee === undefined) return null; + const parts = navigationParts(callee); + if (parts === null) return null; + if (springDynamicLookupCardinality(parts.receiverName, parts.methodName) === null) return null; + const targetTypeName = singleClassLiteralArgument(node); + if (targetTypeName === null) return null; + + const owner = findAncestorBeforeBoundary(node, CALLABLE_NODE_TYPES, NO_CALLABLE_BOUNDARIES); + if (owner === null) return null; + const ownerCapture = nodeToCapture('@spring-dynamic-lookup.owner', owner); + return { + ownerScopeId: makeScopeId({ + filePath, + range: ownerCapture.range, + kind: 'Function', + }), + ownerRange: ownerCapture.range, + receiverName: parts.receiverName, + methodName: parts.methodName, + targetTypeName, + }; +} + +/** Standalone extractor for focused tests; production reuses scope-query call nodes. */ +export function captureKotlinSpringDynamicLookupFacts( + rootNode: SyntaxNode, + filePath: string, +): SpringDynamicLookupFact[] { + return rootNode + .descendantsOfType('call_expression') + .map((node) => captureKotlinSpringDynamicLookupFact(node, filePath)) + .filter((fact): fact is SpringDynamicLookupFact => fact !== null); +} + +/** Attach Kotlin lookup facts for later resolution by the shared DI phase. */ +export const attachKotlinSpringDynamicLookup = createSpringDynamicLookupMetadataAttacher({ + getFacts: getKotlinSpringDynamicLookupFacts, +}); diff --git a/gitnexus/src/core/ingestion/languages/kotlin/spring-message-producers.ts b/gitnexus/src/core/ingestion/languages/kotlin/spring-message-producers.ts new file mode 100644 index 000000000..7f32709c8 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/kotlin/spring-message-producers.ts @@ -0,0 +1,132 @@ +import { makeScopeId } from 'gitnexus-shared'; +import { normalizeSpringFactText } from '../../frameworks/spring/argument-facts.js'; +import { + isSpringMessageProducerMethod, + springMessageProducerTemplateOf, + type SpringMessageProducerFact, +} from '../../frameworks/spring/message-producers.js'; +import { + findAncestorBeforeBoundary, + nodeToCapture, + type SyntaxNode, +} from '../../utils/ast-helpers.js'; +import { kotlinValueArgumentFacts } from './spring-di.js'; + +// Kotlin emits graph callables for functions and secondary constructors. +// `init {}` / primary-constructor bodies have no independent callable node, so +// attributing their publishes to the enclosing Class would violate graph +// semantics. +const CALLABLE_NODE_TYPES = new Set(['function_declaration', 'secondary_constructor']); +/** + * A class body ends the search, so the rule above holds at every depth. + * + * Without it the walk passes THROUGH the body of a class or object declared + * inside a function, and the publish in that body's property initializer — which + * likewise has no callable of its own — is attributed to the enclosing function + * instead of being dropped the way its top-level twin is. + */ +const TYPE_BODY_BOUNDARIES = new Set(['class_body', 'enum_class_body']); + +/** + * Strip null-assertion operators from a receiver. + * + * `?.` carries its marker on the navigation suffix, which the structural split + * already discards, but `!!` wraps the receiver in a `postfix_expression` whose + * text ends in the operator — enough to make `kafkaTemplate!!` fail the + * receiver-name check and lose a publish. Unwrapping is limited to `!!` + * because `counter++` produces the same node shape and is not a receiver name. + */ +function withoutNullAssertions(receiver: SyntaxNode): SyntaxNode { + let current = receiver; + while (current.type === 'postfix_expression') { + const operand = current.namedChildren[0]; + if (operand === undefined) return current; + const onlyNullAssertions = current.children.every( + (child) => child.id === operand.id || child.type === '!!', + ); + if (!onlyNullAssertions) return current; + current = operand; + } + return current; +} + +/** + * Split `receiver.method` structurally rather than by text. + * + * Text splitting would leave the safe-call marker on the receiver + * (`kafkaTemplate?` for `kafkaTemplate?.send(...)`). + */ +function navigationParts(callee: SyntaxNode): { receiverName: string; methodName: string } | null { + if (callee.type !== 'navigation_expression') return null; + const suffix = callee.namedChildren.find((child) => child.type === 'navigation_suffix'); + const receiver = callee.namedChildren.find((child) => child.type !== 'navigation_suffix'); + if (suffix === undefined || receiver === undefined) return null; + const methodName = suffix.namedChildren + .find((child) => child.type === 'simple_identifier') + ?.text.trim(); + if (methodName === undefined) return null; + return { + receiverName: normalizeSpringFactText(withoutNullAssertions(receiver).text), + methodName, + }; +} + +/** + * Capture one messaging-template publish from a Kotlin call already surfaced by + * the scope query, without resolving the destination it names. + * + * The destination argument may be a literal, a reference to a constant that + * lives in another file, or a `${...}` placeholder resolved from configuration; + * all three are recorded as written and left to a later phase. + * + * A call whose argument list did not parse yields NO fact, for the reason given + * on the Java side: error recovery guesses argument boundaries, and this fact + * has no way to say "published somewhere unreadable". + */ +export function captureKotlinSpringMessageProducerFact( + node: SyntaxNode, + filePath: string, +): SpringMessageProducerFact | null { + if (node.type !== 'call_expression') return null; + const callee = node.namedChildren[0]; + if (callee === undefined) return null; + const parts = navigationParts(callee); + if (parts === null || !isSpringMessageProducerMethod(parts.methodName)) return null; + const template = springMessageProducerTemplateOf(parts.receiverName, parts.methodName); + if (template === null) return null; + + const callSuffix = node.namedChildren.find((child) => child.type === 'call_suffix'); + // A trailing-lambda call (`send { ... }`) has no argument list at all, which + // is a different fact from an empty one (`send()`). + const valueArguments = callSuffix?.namedChildren.find( + (child) => child.type === 'value_arguments', + ); + // `null` from the reader means tree-sitter had to recover the list. A publish + // fact exists to carry a destination and has no state for "published + // somewhere unreadable", so the whole fact is withheld rather than reported + // with arguments the source never wrote. + const args = valueArguments === undefined ? undefined : kotlinValueArgumentFacts(valueArguments); + if (args === null) return null; + const owner = findAncestorBeforeBoundary(node, CALLABLE_NODE_TYPES, TYPE_BODY_BOUNDARIES); + if (owner === null) return null; + const ownerCapture = nodeToCapture('@spring-message-producer.owner', owner); + return { + ownerScopeId: makeScopeId({ filePath, range: ownerCapture.range, kind: 'Function' }), + ownerRange: ownerCapture.range, + template, + receiverName: parts.receiverName, + methodName: parts.methodName, + ...(args === undefined ? {} : { args }), + }; +} + +/** Standalone extractor for focused tests; production reuses scope-query call nodes. */ +export function captureKotlinSpringMessageProducerFacts( + rootNode: SyntaxNode, + filePath: string, +): SpringMessageProducerFact[] { + return rootNode + .descendantsOfType('call_expression') + .map((node) => captureKotlinSpringMessageProducerFact(node, filePath)) + .filter((fact): fact is SpringMessageProducerFact => fact !== null); +} diff --git a/gitnexus/src/core/ingestion/languages/kotlin/spring-non-http-handlers.ts b/gitnexus/src/core/ingestion/languages/kotlin/spring-non-http-handlers.ts new file mode 100644 index 000000000..a7b21ffd4 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/kotlin/spring-non-http-handlers.ts @@ -0,0 +1,126 @@ +import { makeScopeId } from 'gitnexus-shared'; +import { + createSpringNonHttpHandlerMetadataAttacher, + hasSpringNonHttpHandlerRelevantAnnotation, + type SpringNonHttpHandlerAnnotationFact, + type SpringNonHttpHandlerFact, +} from '../../frameworks/spring/non-http-handlers.js'; +import { nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; +import { getKotlinSpringNonHttpHandlerFacts } from './capture-side-channel.js'; +import { isKotlinPackageSiblingVisibilityIncomplete } from './package-siblings.js'; +import { kotlinSpringAnnotationFacts } from './spring-di.js'; + +export type KotlinSpringNonHttpHandlerFact = + SpringNonHttpHandlerFact; + +/** + * Local names that reach a handler annotation only through an import alias. + * + * `import ...event.EventListener as SpringEvent` makes `@SpringEvent` a handler + * annotation whose simple name matches nothing, which is why the CALLABLE + * capture below has no name prefilter. The alias is not a mystery at capture + * time, though: the import header states both the local name and the FQN it + * stands for, so the same relevance predicate that Java uses on the annotation + * name can be applied to the IMPORTED name and the answer carried back to the + * alias. That recovers a name-based decision without discarding aliases. + * + * Only aliases are collected. A plain or wildcard import leaves the annotation + * written under its own simple name, which the direct check already sees. + */ +function aliasedHandlerAnnotationNames(classNode: SyntaxNode): ReadonlySet { + let root: SyntaxNode = classNode; + while (root.parent !== null) root = root.parent; + + const headers: SyntaxNode[] = []; + for (const child of root.namedChildren) { + if (child.type === 'import_header') headers.push(child); + else if (child.type === 'import_list') { + for (const header of child.namedChildren) { + if (header.type === 'import_header') headers.push(header); + } + } + } + + const aliases = new Set(); + for (const header of headers) { + const alias = header.namedChildren + .find((child) => child.type === 'import_alias') + ?.namedChildren.find((child) => child.type === 'type_identifier') + ?.text.trim(); + if (alias === undefined || alias.length === 0) continue; + const imported = header.namedChildren.find((child) => child.type === 'identifier')?.text.trim(); + if (imported === undefined || imported.length === 0) continue; + if (hasSpringNonHttpHandlerRelevantAnnotation([{ name: imported }])) aliases.add(alias); + } + return aliases; +} + +/** + * Capture annotated callables conservatively. A simple-name prefilter would + * discard Kotlin aliases (for example, `EventListener as SpringEvent`) before + * the post-import resolver can map the local name back to its annotation FQN. + * + * That conservatism applies to the CALLABLE — every annotated function still + * produces a fact, whatever its annotations are named. It does NOT have to + * apply to the arguments: reading them unconditionally charged every + * `@Transactional` and `@Deprecated` in a repository for data no consumer + * reads, and unlike the callable itself an argument list can be fetched on + * evidence. Arguments are therefore read in a second pass, for callables that + * either carry a handler annotation under its own name or use a local name this + * file aliased to one — the same two-pass shape as Java, with the alias set + * standing in for the name prefilter Kotlin cannot use. + * + * Measured on 200 annotated NON-handler functions in one file: the side-channel + * payload was 41069 bytes before arguments existed, 78797 with them read + * unconditionally, and 41069 again with this pass — byte for byte what it cost + * before the feature. The 200-handler equivalent pays 58649, which is the + * argument text the consumer asked for. + */ +export function captureKotlinSpringNonHttpHandlerFacts( + classNode: SyntaxNode, + filePath: string, +): KotlinSpringNonHttpHandlerFact[] { + const facts: KotlinSpringNonHttpHandlerFact[] = []; + const body = classNode.namedChildren.find( + (child) => child.type === 'class_body' || child.type === 'enum_class_body', + ); + if (body === undefined) return facts; + // Read the import headers at most once per class, and only when some callable + // actually fails the direct name check. + let aliasedHandlerNames: ReadonlySet | undefined; + for (const member of body.namedChildren) { + if (member.type !== 'function_declaration') continue; + const named = kotlinSpringAnnotationFacts(member); + if (named.length === 0) continue; + let readArguments = hasSpringNonHttpHandlerRelevantAnnotation(named); + if (!readArguments) { + aliasedHandlerNames ??= aliasedHandlerAnnotationNames(classNode); + readArguments = named.some( + (annotation) => aliasedHandlerNames?.has(annotation.name) === true, + ); + } + const annotations = readArguments + ? kotlinSpringAnnotationFacts(member, { includeArguments: true }) + : named; + if (annotations.length === 0) continue; + const ownerRange = nodeToCapture('@spring-non-http-handler.owner', member).range; + facts.push({ + ownerScopeId: makeScopeId({ filePath, range: ownerRange, kind: 'Function' }), + ownerFilePath: filePath, + ownerRange, + annotations: annotations.map((annotation) => ({ + name: annotation.name, + ...(annotation.useSiteTarget === undefined + ? {} + : { useSiteTarget: annotation.useSiteTarget }), + ...(annotation.args === undefined ? {} : { args: annotation.args }), + })), + }); + } + return facts; +} + +export const attachKotlinSpringNonHttpHandlerMetadata = createSpringNonHttpHandlerMetadataAttacher({ + getFacts: getKotlinSpringNonHttpHandlerFacts, + isPackageVisibilityIncomplete: isKotlinPackageSiblingVisibilityIncomplete, +}); diff --git a/gitnexus/src/core/ingestion/languages/php/import-target.ts b/gitnexus/src/core/ingestion/languages/php/import-target.ts index 523c2b1c7..9c9d14aa1 100644 --- a/gitnexus/src/core/ingestion/languages/php/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/php/import-target.ts @@ -18,9 +18,16 @@ import type { ParsedFile, ParsedImport, WorkspaceIndex } from 'gitnexus-shared'; import type { ImportResolutionContext } from '../../scope-resolution/contract/scope-resolver.js'; import { resolvePhpImportInternal } from '../../import-resolvers/php.js'; -import type { ComposerConfig } from '../../language-config.js'; -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; +import type { SuffixIndex } from '../../import-resolvers/utils.js'; +import { perFileSet } from '../../import-resolvers/per-file-set.js'; +import { getWorkspaceFileIndex } from '../../import-resolvers/workspace-file-index.js'; +import { + mergeComposerConfigs, + parseComposerConfig, + type ComposerConfig, +} from '../../language-config.js'; +import { readdirSync, readFileSync, type Dirent } from 'node:fs'; +import { dirname, join, relative } from 'node:path'; export interface PhpResolveContext { readonly fromFile: string; @@ -45,19 +52,18 @@ function namespaceDirectories( if (composerConfig === null) return [...directories]; - const normalizedTarget = normalizePhpPath(targetRaw); + const normalizedTarget = normalizePhpPath(targetRaw).replace(/^\/+/, ''); const mappings = [...composerConfig.psr4.entries()].sort((left, right) => { const lengthDifference = right[0].length - left[0].length; return lengthDifference !== 0 ? lengthDifference : left[0].localeCompare(right[0]); }); for (const [namespacePrefix, directoryPrefix] of mappings) { const normalizedPrefix = normalizePhpPath(namespacePrefix); - if ( - normalizedTarget !== normalizedPrefix && - !normalizedTarget.startsWith(`${normalizedPrefix}/`) - ) { - continue; - } + const matchesNamespace = + normalizedPrefix === '' || + normalizedTarget === normalizedPrefix || + normalizedTarget.startsWith(`${normalizedPrefix}/`); + if (!matchesNamespace) continue; const remainder = normalizedTarget.slice(normalizedPrefix.length).replace(/^\//, ''); const separator = remainder.lastIndexOf('/'); @@ -72,12 +78,6 @@ function namespaceDirectories( return [...directories]; } -// A scope-resolution pass shares one stable parsedFiles array across imports. -const phpDirectoryIndexCache = new WeakMap< - readonly ParsedFile[], - ReadonlyMap ->(); - function parentDirectory(filePath: string): string { const normalizedPath = normalizePhpPath(filePath); const separator = normalizedPath.lastIndexOf('/'); @@ -85,76 +85,277 @@ function parentDirectory(filePath: string): string { } function directoryAliases(filePath: string): string[] { - const normalizedPath = normalizePhpPath(filePath); - const separator = normalizedPath.lastIndexOf('/'); - if (separator < 0) return ['']; - - const parent = normalizedPath.slice(0, separator); - const aliases = new Set([parent]); - const segments = parent.split('/').filter(Boolean); - for (let index = 0; index < segments.length; index++) { - aliases.add(segments.slice(index).join('/')); - } - return [...aliases]; + return [parentDirectory(filePath)]; } -function filesByDirectory( - parsedFiles: readonly ParsedFile[], -): ReadonlyMap { - const cached = phpDirectoryIndexCache.get(parsedFiles); - if (cached) return cached; - - const mutable = new Map(); - for (const parsed of parsedFiles) { - for (const directory of directoryAliases(parsed.filePath)) { - const files = mutable.get(directory) ?? []; - files.push(parsed); - mutable.set(directory, files); +/** + * Exact repository-relative directory → the files under it, built once per pass. + * + * A scope-resolution pass shares one stable `parsedFiles` array across imports, + * so the array identity is the memo key — see `perFileSet`. + */ +const filesByDirectory = perFileSet( + (parsedFiles: readonly ParsedFile[]): ReadonlyMap => { + const mutable = new Map(); + for (const parsed of parsedFiles) { + for (const directory of directoryAliases(parsed.filePath)) { + const files = mutable.get(directory) ?? []; + files.push(parsed); + mutable.set(directory, files); + } } - } - phpDirectoryIndexCache.set(parsedFiles, mutable); - return mutable; + return mutable; + }, +); + +// ─── workspace index (#2901) ─────────────────────────────────────────────── + +/** + * PHP's view of the shared per-file-set workspace index. + * + * Both adapters below used to materialize `[...allFilePaths]` twice per import + * and then hand `resolvePhpImportInternal` an `index` of `undefined`, which + * dropped it onto `suffixResolve`'s linear `findIndex` — one full pass over + * every file per path-part × per extension (≈50 extensions). That is the 98 ms + * per import measured at 20k files, and the arrays were the small half of it. + * + * PASSING THE SHARED `SuffixIndex` STRAIGHT THROUGH IS NOT A HOIST — IT MOVES + * IMPORTS EDGES. `resolvePhpImportInternal` reads the index at three sites, and + * all three answer a DIFFERENT question than the scan they short-circuit + * (measured, one example each): + * + * 1. `index.getInsensitive(filePath)` on the PSR-4 class-style leg has no + * no-index counterpart at all — that leg is `allFiles.has(filePath)`, an + * exact whole-path test. The index turns it into a case-insensitive SUFFIX + * probe, so `App\Models\User` under `psr-4: {"App\\": "src"}` would start + * matching `vendor/x/src/models/user.php`. + * 2. `index.getFilesInDir(nsDir, '.php')` is keyed on every directory SUFFIX, + * while the scan it replaces is anchored at the repo root + * (`f.startsWith(nsDir + '/')`). With `app/Models/Aaa.php` and + * `vendor/pkg/app/Models/Zed.php` present, `use function App\Models\getUser` + * resolves to the former today and to the latter with the raw index. + * 3. `suffixResolve` with an index probes `index.get(S) || index.getInsensitive(S)`, + * which matches WHOLE paths too (`buildSuffixIndex` indexes the `j = 0` + * suffix); the scan compares `endsWith('/' + S)` and so can only match a + * PROPER suffix. Root-level `Foo.php` is unresolvable for `use Foo;` today + * and resolvable with the raw index; and where both match, + * `App/Models/User.php` (whole path, later in iteration order) would beat + * `vendor/x/Models/User.php` (proper suffix, earlier), which is the file the + * scan returns. + * + * So this builds a PARITY view instead: the same memoized arrays, and a + * `SuffixIndex` whose three methods reproduce the no-index answers exactly. + * - `getInsensitive` returns `undefined` unconditionally, which makes site 1 a + * no-op and falls through exactly as `index === undefined` did. It is safe to + * hollow out because `suffixResolve` reads it only as + * `get(S) || getInsensitive(S)`, so `get` can carry both halves — see below. + * - `getFilesInDir` answers from a root-anchored raw-path directory bucket, so + * site 2 returns what the scan returned, in the same order. + * - `get` answers site 3, defined as "first file in Set order whose normalized + * path has `S` as a proper segment suffix, compared case-insensitively". + * That single rule IS the scan: its predicate is + * `endsWith(p) || toLowerCase().endsWith(p.toLowerCase())`, whose first + * disjunct is subsumed by the second, so a case-sensitive hit never outranks + * an earlier case-insensitive one the way `get() || getInsensitive()` does. + * + * `get` is built on the shared `index.getInsensitive`, which is that same rule + * plus the whole-path (`j = 0`) entries. The correction needs one extra map, and + * only O(files) of it: the shared lookup can only over-match when `S` IS some + * file's whole normalized path, so `firstProperSuffixMatch` is keyed on exactly + * those strings. (Whole-string vs per-segment lowercasing agree here: no case + * mapping in Unicode produces or consumes `/`, so `lower(p).split('/')` and + * `p.split('/').map(lower)` are the same list.) + * + * `index.getInsensitive` is the ONLY shared-index method this file calls — it + * never asks the case-sensitive question — which is why `buildSuffixIndex` + * defers its two suffix maps rather than fusing them: PHP builds and retains + * one of the pair instead of both (34.49 MiB of 69.85 MiB at 32 000 paths). + * + * The two maps built HERE are deferred for the same reason and are each cheap + * only in ENTRIES, not in the walk that fills them — see the notes on + * `getFirstProperSuffixMatch` (O(paths × depth) to fill, typically zero entries) + * and `getFilesByRawDirectory` (unreachable without a `composer.json`). + */ +interface PhpWorkspaceIndex { + /** Every path, backslashes normalized to `/`. Parallel to `all`. */ + readonly normalized: readonly string[]; + /** Every path, exactly as it appears in the Set. Parallel to `normalized`. */ + readonly all: readonly string[]; + /** Scan-equivalent `SuffixIndex` for `resolvePhpImportInternal`. */ + readonly suffixIndex: SuffixIndex; } +/** Memoized on the `allFilePaths` Set identity, like `getWorkspaceFileIndex`. */ +const getPhpWorkspaceIndex = perFileSet((allFilePaths: ReadonlySet): PhpWorkspaceIndex => { + // The Set is passed THROUGH to the shared cache, never copied — a defensive + // `new Set(...)` here or in `scope-resolver.ts` would hand both WeakMaps a + // fresh key per import and silently restore O(imports × files) (#1918 P1). + const { normalized, all, index } = getWorkspaceFileIndex(allFilePaths); + + /** + * Whole-path-lowercase → the first PROPER-suffix match, the correction `get` + * applies to a whole-path hit from the shared index. + * + * DEFERRED, and deferred all the way to the branch that reads it rather than + * to the first `get`. The builder walks every slash of every path and + * lowercases a slice at each, so it is O(paths × depth) in both time and + * allocation — measured 46.0 ms at 32 000 paths on the PHP arm of + * `bench/import-target/`, filling a map that held ZERO entries, because it + * can only hold one when some file's whole path is also a proper suffix of + * another's. Most repos never produce that, and the ones that do reach this + * branch only for the imports that actually hit a whole path. Pure function + * of `normalized`/`all`, both of which the returned object already retains, + * so building it late is behaviour-identical and retains nothing new. + * + * `wholePathLower` is a scratch set of the builder, not state: nothing reads + * it afterwards, so deferring the map defers it too. + */ + let firstProperSuffixMatch: Map | null = null; + const getFirstProperSuffixMatch = (): Map => { + if (firstProperSuffixMatch !== null) return firstProperSuffixMatch; + const wholePathLower = new Set(); + for (const path of normalized) wholePathLower.add(path.toLowerCase()); + + // Only the suffixes that a whole path can shadow are worth storing; see the + // header. Built from `normalized`, so it costs no traversal of the Set. + const built = new Map(); + for (let i = 0; i < normalized.length; i++) { + const lower = normalized[i].toLowerCase(); + for (let slash = lower.indexOf('/'); slash >= 0; slash = lower.indexOf('/', slash + 1)) { + const suffix = lower.slice(slash + 1); + if (!wholePathLower.has(suffix)) continue; + if (!built.has(suffix)) built.set(suffix, all[i]); + } + } + firstProperSuffixMatch = built; + return built; + }; + + /** + * Raw directory → the files directly in it, for `getFilesInDir`. + * + * DEFERRED for the same reason as the shared `dirMap` (#2903), and here the + * case is stronger: `getFilesInDir` has exactly one caller, + * `import-resolvers/php.ts`'s PSR-4 function/constant fallback, and that + * caller sits inside `if (composerConfig) { … }`. `resolvePhpImportTarget` + * hard-codes `composerConfig: null`, so on the LanguageProvider path the map + * is statically unreachable; on the ScopeResolver path it is reachable only + * in a repo that has a parseable `composer.json` with `autoload.psr-4`. + * Measured 6.8 ms / 3.56 MiB at 32 000 paths, paid by every PHP repo without + * one. Pure function of `all`, which the returned object retains. + */ + let filesByRawDirectory: Map | null = null; + const getFilesByRawDirectory = (): Map => { + if (filesByRawDirectory !== null) return filesByRawDirectory; + // Raw paths, not normalized: the scan this replaces tests `f.startsWith(...)` + // against the Set's own strings, so a backslash path is a miss there and must + // stay a miss here. Insertion order is Set order, so `[0]` is the file the + // scan would have returned first. + const built = new Map(); + for (const raw of all) { + const separator = raw.lastIndexOf('/'); + if (separator < 0) continue; + const directory = raw.slice(0, separator); + const bucket = built.get(directory); + if (bucket === undefined) built.set(directory, [raw]); + else bucket.push(raw); + } + filesByRawDirectory = built; + return built; + }; + + const suffixIndex: SuffixIndex = { + get: (suffix: string): string | undefined => { + const hit = index.getInsensitive(suffix); + if (hit === undefined) return undefined; + const lower = suffix.toLowerCase(); + // A proper-suffix hit is already the scan's answer: the shared map holds + // the first file matching EITHER way, so nothing earlier matched at all. + if (hit.replace(/\\/g, '/').toLowerCase() !== lower) return hit; + // Whole-path hit — invisible to `endsWith('/' + S)`. The scan keeps going. + // The only branch that needs the correction map, hence the only one that + // builds it. + return getFirstProperSuffixMatch().get(lower); + }, + // Site 1 must stay a no-op, and `suffixResolve` folds this into `get`. + getInsensitive: (): undefined => undefined, + getFilesInDir: (dirSuffix: string, extension: string): string[] => { + // `nsDirPrefix` is `nsDir` when it already ends in `/`, else `nsDir + '/'` + // — either way the directory is `nsDir` minus one trailing slash. + const directory = dirSuffix.endsWith('/') ? dirSuffix.slice(0, -1) : dirSuffix; + const bucket = getFilesByRawDirectory().get(directory); + if (bucket === undefined) return []; + return bucket.filter((file) => file.endsWith(extension)); + }, + }; + + return { normalized, all, suffixIndex }; +}); + // ─── loadResolutionConfig ────────────────────────────────────────────────── /** - * Load and parse `composer.json` from the repo root. Returns a - * `ComposerConfig` object (PSR-4 namespace → directory mappings) or - * `null` when no `composer.json` is present or it cannot be parsed. + * Load and parse repository and package-local `composer.json` manifests. + * Package mappings are rebased to repository-relative paths before merging. * * The result is threaded into each `resolvePhpImportInternal` call as * the `composerConfig` argument. */ export function loadPhpComposerConfig(repoPath: string): ComposerConfig | null { - try { - const composerPath = join(repoPath, 'composer.json'); - const raw = readFileSync(composerPath, 'utf8'); - const parsed = JSON.parse(raw) as unknown; - if (typeof parsed !== 'object' || parsed === null) return null; + const skipDirectories = new Set([ + '.git', + '.gitnexus', + 'node_modules', + 'vendor', + 'dist', + 'build', + 'coverage', + ]); + const pending = [repoPath]; + const manifests: string[] = []; + let incomplete = false; + let visitedDirectories = 0; - const composer = parsed as Record; - const autoload = composer['autoload'] as Record | undefined; - if (autoload === undefined) return null; - - const psr4Raw = (autoload['psr-4'] ?? {}) as Record; - const psr4 = new Map(); - - for (const [ns, dirs] of Object.entries(psr4Raw)) { - // namespace prefix ends with `\` — keep as-is; resolver strips it - const normalizedNs = ns.replace(/\\$/, ''); - const dir = Array.isArray(dirs) ? dirs[0] : dirs; - if (typeof dir === 'string') { - // Normalize directory path (strip trailing slash) - const normalizedDir = dir.replace(/\/+$/, ''); - psr4.set(normalizedNs, normalizedDir); + while (pending.length > 0) { + const directory = pending.pop(); + if (directory === undefined) break; + if (++visitedDirectories > 20_000) { + incomplete = true; + break; + } + let entries: Dirent[]; + try { + entries = readdirSync(directory, { withFileTypes: true }).sort((left, right) => + left.name.localeCompare(right.name), + ); + } catch { + incomplete = true; + continue; + } + for (const entry of entries) { + if (entry.isFile() && entry.name === 'composer.json') { + manifests.push(join(directory, entry.name)); + } else if (entry.isDirectory() && !skipDirectories.has(entry.name)) { + pending.push(join(directory, entry.name)); } } - - return { psr4 }; - } catch { - return null; } + + const configs: ComposerConfig[] = []; + for (const manifest of manifests.sort()) { + try { + const baseDir = normalizePhpPath(relative(repoPath, dirname(manifest))); + const config = parseComposerConfig(JSON.parse(readFileSync(manifest, 'utf8')), baseDir); + if (config !== null) configs.push(config); + } catch { + incomplete = true; + } + } + + const merged = mergeComposerConfigs(configs); + if (merged === null) return null; + if (incomplete) merged.hasUnmodeledAutoload = true; + return merged; } // ─── resolvePhpImportTarget ──────────────────────────────────────────────── @@ -181,17 +382,17 @@ export function resolvePhpImportTarget( if (parsedImport.kind === 'dynamic-unresolved') return null; if (parsedImport.targetRaw === null || parsedImport.targetRaw === '') return null; + // Cast, not copy: `getPhpWorkspaceIndex` memoizes on this exact Set object. const allFiles = ctx.allFilePaths as Set; - const normalizedFileList = [...allFiles].map((f) => f.replace(/\\/g, '/')); - const allFileList = [...allFiles]; + const { normalized, all, suffixIndex } = getPhpWorkspaceIndex(allFiles); return resolvePhpImportInternal( parsedImport.targetRaw, null, // composerConfig not available through LanguageProvider path allFiles, - normalizedFileList, - allFileList, - undefined, + normalized, + all, + suffixIndex, ); } @@ -216,17 +417,17 @@ export function resolvePhpImportTargetInternal( ? (resolutionConfig as ComposerConfig) : null; + // Cast, not copy: `getPhpWorkspaceIndex` memoizes on this exact Set object. const allFiles = allFilePaths as Set; - const normalizedFileList = [...allFiles].map((f) => f.replace(/\\/g, '/')); - const allFileList = [...allFiles]; + const { normalized, all, suffixIndex } = getPhpWorkspaceIndex(allFiles); const resolved = resolvePhpImportInternal( targetRaw, composerConfig, allFiles, - normalizedFileList, - allFileList, - undefined, + normalized, + all, + suffixIndex, ); const parsedImport = context?.parsedImport; @@ -251,11 +452,7 @@ export function resolvePhpImportTargetInternal( ...new Set( directories.flatMap((directory) => { const files = directoryIndex.get(normalizePhpPath(directory)) ?? []; - // A suffix alias can match directories under different roots (for - // example app/Models and vendor/pkg/app/Models). Picking either root - // would be a guess, so fail closed to the composer resolution instead. - const distinctParents = new Set(files.map((file) => parentDirectory(file.filePath))); - return distinctParents.size > 1 ? [] : files; + return files; }), ), ]; diff --git a/gitnexus/src/core/ingestion/languages/python.ts b/gitnexus/src/core/ingestion/languages/python.ts index f9c345b4b..ce8d0fa90 100644 --- a/gitnexus/src/core/ingestion/languages/python.ts +++ b/gitnexus/src/core/ingestion/languages/python.ts @@ -44,6 +44,8 @@ import { } from './python/index.js'; import { extractDjangoRoutes } from '../route-extractors/django.js'; import { discoverDjangoRootUrls } from '../route-extractors/django-root-discovery.js'; +import { extractPythonModuleConstants } from '../route-extractors/python-const-resolver.js'; +import { pythonDecoratorRouteHandlerName } from '../route-extractors/python-decorator-handler.js'; const BUILT_INS: ReadonlySet = new Set([ 'print', @@ -142,6 +144,7 @@ export const pythonProvider = defineLanguage({ discoverDjangoRootUrls(files, contentMap, reader), extractRoutes: (tree, filePath, reader, parser) => parser ? extractDjangoRoutes(tree, filePath, parser, reader) : [], + decoratorRouteHandlerName: pythonDecoratorRouteHandlerName, labelOverride: pythonFunctionDefinitionLabel, // ── RFC #909 Ring 3: scope-based resolution hooks (RFC §5) ────────── @@ -158,4 +161,17 @@ export const pythonProvider = defineLanguage({ receiverBinding: pythonReceiverBinding, arityCompatibility: pythonArityCompatibility, resolveImportTarget: resolvePythonImportTarget, + + // ── #2391 constant harvest, provider-hook form (#2980): module-level string + // constants + from-imports for non-literal decorator route paths. Bare-name + // refs fold through the shared resolver (no foldRoutePathOperands needed). + // No `moduleConstantHeuristic`: Python harvests unconditionally, exactly as + // #2391 shipped it. A content gate was tried here and removed on review — it + // required `NAME` immediately followed by `=`, so it silently dropped the two + // idiomatic typed-FastAPI shapes (`API: str = "/api"`, + // `API: Final[str] = "/api"`) and every composed constant whose RHS starts + // with an identifier (`USERS = BASE + "/users"`), i.e. it REGRESSED routes + // that already resolve on main. The worker treats a missing heuristic as + // default-open; only Java opts into a gate, where the cost actually bites. + extractModuleConstants: extractPythonModuleConstants, }); diff --git a/gitnexus/src/core/ingestion/languages/python/import-decomposer.ts b/gitnexus/src/core/ingestion/languages/python/import-decomposer.ts index 7cc855796..5a7d8cba2 100644 --- a/gitnexus/src/core/ingestion/languages/python/import-decomposer.ts +++ b/gitnexus/src/core/ingestion/languages/python/import-decomposer.ts @@ -13,6 +13,7 @@ import type { Capture, CaptureMatch } from 'gitnexus-shared'; import { + findAncestorBeforeBoundary, findChild, nodeToCapture, syntheticCapture, @@ -23,12 +24,30 @@ import { * `interpretPythonImport`. */ type ImportKind = 'plain' | 'aliased' | 'from' | 'from-alias' | 'wildcard' | 'dynamic'; +/** + * The only two constructs that stop a module-level `from m import x` from + * publishing `x` as `.x`. Python has no block scope, so an import + * under `if` / `try` / `for` / `with` still publishes when its branch runs — + * verified against CPython 3.11; only `def` and `class` bodies suppress it. + */ +const PUBLICATION_SUPPRESSING_ANCESTORS: ReadonlySet = new Set([ + 'function_definition', + 'class_definition', +]); +const NO_BOUNDARY: ReadonlySet = new Set(); + interface ImportSpec { readonly kind: ImportKind; readonly source: string; readonly name: string; readonly alias?: string; readonly atNode: SyntaxNode; + /** + * Statement sits at module level, so the bound name joins the module + * namespace and is importable from this module. Read by + * `interpretPythonImport` to set `ParsedImport.reexportsName`. + */ + readonly publishesToModule?: boolean; } export function splitImportStatement(stmtNode: SyntaxNode): CaptureMatch[] { @@ -76,6 +95,9 @@ function splitImportFromStmt(stmtNode: SyntaxNode): CaptureMatch[] { const out: CaptureMatch[] = []; const moduleField = stmtNode.childForFieldName('module_name'); const moduleText = moduleField?.text ?? ''; + // Once per statement, not once per name. + const publishesToModule = + findAncestorBeforeBoundary(stmtNode, PUBLICATION_SUPPRESSING_ANCESTORS, NO_BOUNDARY) === null; // Wildcard? tree-sitter-python represents `*` as a `wildcard_import` // child and emits no name children. @@ -105,6 +127,7 @@ function splitImportFromStmt(stmtNode: SyntaxNode): CaptureMatch[] { source: moduleText, name: child.text, atNode: child, + publishesToModule, }), ); } else if (child.type === 'aliased_import') { @@ -118,6 +141,7 @@ function splitImportFromStmt(stmtNode: SyntaxNode): CaptureMatch[] { name: dotted.text, alias: alias.text, atNode: child, + publishesToModule, }), ); } @@ -137,5 +161,11 @@ function buildImportMatch(stmtNode: SyntaxNode, spec: ImportSpec): CaptureMatch if (spec.alias !== undefined) { m['@import.alias'] = syntheticCapture('@import.alias', spec.atNode, spec.alias); } + // Anchored at `spec.atNode`, never `stmtNode`: `anchorCaptureFor` picks the + // broadest span with a strict `>`, so a statement-wide span here would tie + // with `@import.statement` and let key order decide the anchor. + if (spec.publishesToModule === true) { + m['@import.publishes'] = syntheticCapture('@import.publishes', spec.atNode, 'module'); + } return m; } diff --git a/gitnexus/src/core/ingestion/languages/python/import-target.ts b/gitnexus/src/core/ingestion/languages/python/import-target.ts index d06912cf5..e31f038b0 100644 --- a/gitnexus/src/core/ingestion/languages/python/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/python/import-target.ts @@ -11,8 +11,13 @@ */ import type { ParsedFile, ParsedImport, WorkspaceIndex } from 'gitnexus-shared'; +import { perFileSet } from '../../import-resolvers/per-file-set.js'; +import { + getPythonFileIndex, + importerAncestors, + importerDirOf, +} from '../../import-resolvers/python-file-index.js'; import { resolvePythonImportInternal } from '../../import-resolvers/python.js'; -import { recordPythonFileIndexBuild } from './index-stats.js'; export interface PythonResolveContext { readonly fromFile: string; @@ -82,7 +87,35 @@ export function resolvePythonImportTarget( workspaceIndex, ); if (submodule !== null) return submodule; - if (packageTarget !== null) return packageTarget; + + // `return packageTarget`, not `if (packageTarget !== null) return …` — + // falling through when it is null RE-RAN THE ENTIRE TAIL BELOW, a second + // time, with byte-identical arguments. + // + // `packageTarget` IS this function's tail for this import. The recursion + // above differs from the outer frame in exactly one field, + // `targetIncludesImportedName`, whose only effect is to make + // `pythonImportedSubmoduleTarget` return null and so skip this branch: the + // spread preserves `kind` (still `named`/`alias`, so the + // `dynamic-unresolved` guard cannot fire) and `targetRaw` (which already + // passed the null/empty guard), and `workspaceIndex` is the same object, so + // `ctx.fromFile`, `ctx.allFilePaths` and `ctx.parsedFiles` are the same + // references. The recursion therefore ran `resolvePythonImportInternal` → + // relative gate → `hasRepoCandidate` → `resolveAbsoluteFromFiles` on + // exactly the inputs the fallthrough would use. + // + // That tail is a pure function of (`fromFile`, `targetRaw`, + // `allFilePaths`): it only reads the Set and indexes memoized on the Set, + // and the `submodule` probe in between is equally read-only, so nothing can + // have changed the answer. Reaching this line means the tail already + // returned null; running it again returns null again, after another + // proximity probe and another full ancestor walk to the workspace root. + // + // Measured before this change, `from x import y` at four directory + // components: 24 `allFilePaths.has` probes per import, of which probes + // 12-23 were byte-identical repeats of 0-11. `python-import-probe-count + // .test.ts` is the gate. + return packageTarget; } // PEP-328 relative + single-segment proximity bare imports. @@ -122,13 +155,43 @@ export function resolvePythonImportTarget( return resolveAbsoluteFromFiles(pathLike, ctx.allFilePaths, ctx.fromFile); } +/** + * Answers "does this package expose `importedName` as an attribute?" from + * `localDefs` alone — so it says no for a name the package only re-exports. + * + * KNOWN DIVERGENCE from `buildReexportClosures`, which since #2864 does carry + * re-exported names (`ParsedImport.reexportsName`). With + * `pkg/__init__.py: from .impl import log`, `pkg/impl.py: def log`, and a + * same-named `pkg/log.py`, this returns false, the caller falls through to the + * submodule probe, and `from pkg import log` targets `pkg/log.py` — where + * `log` is not a local def either, so the edge ends unresolved and the closure + * is never consulted, for exactly the case it was built for. CPython binds + * `pkg.log` to the function. + * + * NOT fixed by reusing the flag here, which is the obvious three-line change + * and is wrong: `reexportsName` is also set for `pkg/__init__.py: from . + * import log`, where CPython binds `pkg.log` to the **module** `pkg/log.py` + * (verified on 3.11) and returning true here would kill the correct namespace + * edge. Separating the two needs the re-export's own resolved target, i.e. + * re-entering `resolvePythonImportTarget` from a different `fromFile` — and + * that classification is what open issue #2882 is about, so it belongs with + * that fix rather than bolted on here. Not a regression: both halves behave + * exactly as they did before #2864. + * + * The `parsedFiles.find` this used to open with was the same O(imports x files) + * shape #2913 removes on the path Set, keyed on the other collection the + * orchestrator threads: every import whose package probe resolves scanned the + * whole parsed workspace, and on a repo where `from pkg import X` usually + * resolves that is most imports. `parsedFileByPath` replaces it with one pass + * per pass. + */ function pythonFileExportsName( targetFile: string, importedName: string, parsedFiles: readonly ParsedFile[] | undefined, ): boolean { if (parsedFiles === undefined) return false; - const parsed = parsedFiles.find((file) => file.filePath === targetFile); + const parsed = parsedFileByPath(parsedFiles).get(targetFile); if (parsed === undefined) return false; return parsed.localDefs.some((def) => { const qualifiedName = def.qualifiedName; @@ -138,6 +201,27 @@ function pythonFileExportsName( }); } +/** + * `filePath -> ParsedFile`, memoized on the identity of the pass's + * `parsedFiles` array — the second stable object the orchestrator threads + * through `resolveImportTarget`, beside the path Set. + * + * FIRST WINS on a duplicated path, which is what `Array.prototype.find` + * returned, so the answer is unchanged for a workspace that somehow parsed one + * path twice. Values are references to the array's own elements: the Map costs + * one pointer per parsed file and, living in a `WeakMap` keyed on the array, + * is reclaimed with the pass rather than accumulating across runs (#2649). + */ +const parsedFileByPath = perFileSet( + (parsedFiles: readonly ParsedFile[]): Map => { + const byPath = new Map(); + for (const file of parsedFiles) { + if (!byPath.has(file.filePath)) byPath.set(file.filePath, file); + } + return byPath; + }, +); + /** * Resolve `package/sub/module` style paths (already dot-flattened) to a * concrete file in `allFilePaths`. Tries the exact path first, then walks @@ -173,19 +257,44 @@ function resolveAbsoluteFromFiles( if (allFilePaths.has(directFile)) return directFile; if (allFilePaths.has(directPkg)) return directPkg; + // Both remaining tiers — the ancestor walk and the suffix fallback — can only + // ever land on a file whose basename is `.py`, or on an `__init__.py` + // whose parent directory is named ``. The two buckets the suffix + // fallback already needs therefore also decide, in O(1) and before the walk, + // whether the walk can hit at all: neither bucket present means no tier below + // can match, and one bucket absent removes that tier's probe from EVERY step + // of the walk. On the deep corpus that is half the walk's probes (#2913). + // + // `pythonSegmentAbsent` states this same rule for the single-segment bare + // tier. It is deliberately not called here: that tier needs only the answer, + // this one needs the candidate ARRAYS for the suffix fallback below, so + // sharing would mean two extra `has` lookups per import to save four lines. + const index = getPythonFileIndex(allFilePaths); + const lastSeg = pathLike.slice(pathLike.lastIndexOf('/') + 1); + const moduleCandidates = index.byBasename.get(`${lastSeg}.py`); + const packageCandidates = index.byInitParent.get(`${lastSeg}/__init__.py`); + const mayBeModule = moduleCandidates !== undefined; + // `byInitParent` skips `__init__.py` files whose parent directory name is + // empty (a doubled separator), so an empty `` — a target spelled + // with a trailing dot — cannot use the bucket as proof of absence and keeps + // probing exactly as before. + const mayBePackage = packageCandidates !== undefined || lastSeg === ''; + if (!mayBeModule && !mayBePackage) return null; + // Ancestor walk — match the single-segment resolver's behavior at - // multi-segment granularity. Closest match wins. Stop at `i > 0` because - // `i === 0` would re-check the workspace-root candidates already covered - // by the direct check above. - const importerDir = fromFile.replace(/\\/g, '/').split('/').slice(0, -1).join('/'); - if (importerDir) { - const dirParts = importerDir.split('/').filter(Boolean); - for (let i = dirParts.length; i > 0; i--) { - const ancestor = dirParts.slice(0, i).join('/'); - const prefix = `${ancestor}/`; - const candidateFile = `${prefix}${directFile}`; - const candidatePkg = `${prefix}${directPkg}`; + // multi-segment granularity. Closest match wins. The chain stops short of the + // workspace root because the root candidates are the direct check above. + // + // The chain comes from `importerAncestors`, which builds it ONCE per importer + // directory per pass. Rebuilding it here — one `slice(0, i).join('/')` per + // path component, on every import — was half of the depth quadratic in #2913. + for (const ancestor of importerAncestors(index, importerDirOf(fromFile))) { + if (mayBeModule) { + const candidateFile = `${ancestor}/${directFile}`; if (allFilePaths.has(candidateFile)) return candidateFile; + } + if (mayBePackage) { + const candidatePkg = `${ancestor}/${directPkg}`; if (allFilePaths.has(candidatePkg)) return candidatePkg; } } @@ -214,17 +323,15 @@ function resolveAbsoluteFromFiles( // shared buildSuffixIndex is deliberately NOT used: it keeps only one // path per suffix (longest wins) and so cannot reproduce this exact // fewest-segments-then-lexicographic tie-break across all candidates. - const index = getPythonFileIndex(allFilePaths); - const lastSeg = pathLike.slice(pathLike.lastIndexOf('/') + 1); const matches: { raw: string; norm: string }[] = []; - for (const cand of index.byBasename.get(`${lastSeg}.py`) ?? []) { + for (const cand of moduleCandidates ?? []) { if (cand.norm.endsWith(suffixFile)) matches.push(cand); } // Package form: only `__init__.py` files whose parent dir is named `` // can match `…//__init__.py` — look them up by parent key (P2b) and // confirm the full suffix. Same final candidate set as the old `__init__.py` // scan, just without iterating unrelated packages. - for (const cand of index.byInitParent.get(`${lastSeg}/__init__.py`) ?? []) { + for (const cand of packageCandidates ?? []) { if (cand.norm.endsWith(suffixPkg)) matches.push(cand); } if (matches.length === 0) return null; @@ -270,131 +377,33 @@ function hasRepoCandidate( const rootFile = `${leadingSegment}.py`; const initFile = `${leadingSegment}/__init__.py`; - // Build importer-ancestor prefixes: for `backend/routers/cron.py`, - // produces `["backend/routers/services/", "backend/services/"]` for - // segment `services` (closest first, root excluded — covered above). - const importerDir = fromFile.replace(/\\/g, '/').split('/').slice(0, -1).join('/'); - const dirParts = importerDir ? importerDir.split('/').filter(Boolean) : []; - const ancestorPrefixes: string[] = []; - for (let i = dirParts.length; i > 0; i--) { - ancestorPrefixes.push(`${dirParts.slice(0, i).join('/')}/${leadingSegment}/`); - } - // Indexed equivalents of the old O(files) scan: // (1) `f === rootFile || f === initFile` -> normalized-path membership. // (2) `f.startsWith(`${seg}/`) && f.endsWith('.py')` -> some .py file lives // under directory `${seg}/`, i.e. `${seg}/` is a known .py dir prefix. // (3) ancestor namespace case -> `${ancestor}/${seg}/` is a known .py dir - // prefix. + // prefix, for some ancestor of the importer's directory. const index = getPythonFileIndex(allFilePaths); if (index.normSet.has(rootFile) || index.normSet.has(initFile)) return true; if (index.dirPrefixes.has(prefix)) return true; - for (const ap of ancestorPrefixes) { - if (index.dirPrefixes.has(ap)) return true; + // (3) used to MATERIALIZE one `${ancestor}/${seg}/` string per component of + // the importer's directory, eagerly, before checks (1) and (2) had even run — + // O(depth^2) characters on every import, and the other half of #2913. Two + // things replace that: `nestedDirNames` answers "is `seg` the name of any + // directory sitting under a non-empty parent?" in O(1), which is `false` for + // every external import (`os`, `django`, an unknown distribution) and skips + // the walk outright; and what remains walks the per-directory ancestor chain, + // built once per pass, closest first, so the common in-repo hit exits after a + // step or two. `nestedDirNames` is exact, not a filter: `${A}/${seg}/` can + // only be a directory prefix if `seg` names a directory under the non-empty + // parent `A`, so a miss here means the old loop would have missed too. + if (!index.nestedDirNames.has(leadingSegment)) return false; + for (const ancestor of importerAncestors(index, importerDirOf(fromFile))) { + if (index.dirPrefixes.has(`${ancestor}/${prefix}`)) return true; } return false; } -/** - * Per-file-set index for Python import resolution, memoized on the - * `allFilePaths` Set object (the same Set is passed for every import in a run, - * so the index is built once and reused). Replaces the per-import O(files) - * scans in `resolveAbsoluteFromFiles` (suffix match) and `hasRepoCandidate` - * (package-existence gate) with O(1)/O(bucket) lookups. - * - * - `normSet`: every file path, normalized to forward slashes (for the exact - * `f === rootFile|initFile` membership checks). - * - `byBasename`: last path component (e.g. `models.py`, `__init__.py`) -> - * all `{ raw, norm }` candidates, so suffix matches can be gathered from the - * relevant bucket and the exact tie-break applied across ALL of them. - * - `byInitParent`: `__init__.py` files keyed by their last TWO components - * (`/__init__.py`). The package suffix lookup (`pkg.sub` -> - * `…/sub/__init__.py`) targets only same-named package dirs via this map - * instead of scanning every `__init__.py` in the repo — the common - * multi-segment import path no longer scales with package count - * (PR #1918 review P2b). `__init__.py` files stay in `byBasename` too, for - * the rarer explicit `pkg.__init__` import that resolves via the module - * (`….py`) lookup. - * - `dirPrefixes`: every directory prefix of a `.py` file, trailing-slashed - * (`a/b/c.py` -> `a/`, `a/b/`), for "is there a .py file under `/`". - */ -interface PythonFileIndex { - readonly normSet: Set; - readonly byBasename: Map; - readonly byInitParent: Map; - readonly dirPrefixes: Set; -} - -const PYTHON_FILE_INDEX_CACHE = new WeakMap, PythonFileIndex>(); - -function getPythonFileIndex(allFilePaths: ReadonlySet): PythonFileIndex { - const cached = PYTHON_FILE_INDEX_CACHE.get(allFilePaths); - if (cached !== undefined) return cached; - // Cache miss: materialize a fresh index. Counted so a test can assert this - // happens once per run, not once per import (PR #1918 review P1 guard). - recordPythonFileIndexBuild(); - - const normSet = new Set(); - const byBasename = new Map(); - const byInitParent = new Map(); - const dirPrefixes = new Set(); - - for (const raw of allFilePaths) { - const norm = raw.replace(/\\/g, '/'); - // Python import resolution only ever queries `.py` paths: module `.py` - // and package `/__init__.py` membership (normSet), `.py` / - // `__init__.py` basename buckets (byBasename), and `.py` directory prefixes - // (dirPrefixes). Non-`.py` files can never match any of those, so skip them - // — they were dead weight in every structure on polyglot monorepos - // (PR #1918 review P3b; dirPrefixes was already `.py`-gated). - if (!norm.endsWith('.py')) continue; - normSet.add(norm); - - const lastSlash = norm.lastIndexOf('/'); - const base = lastSlash >= 0 ? norm.slice(lastSlash + 1) : norm; - let bucket = byBasename.get(base); - if (bucket === undefined) { - bucket = []; - byBasename.set(base, bucket); - } - bucket.push({ raw, norm }); - - // Package files also get a parent-keyed bucket so a `pkg.sub` lookup hits - // only `…/sub/__init__.py` candidates, not every `__init__.py` (P2b). - if (base === '__init__.py' && lastSlash >= 0) { - const dir = norm.slice(0, lastSlash); - const parentSlash = dir.lastIndexOf('/'); - const parentName = parentSlash >= 0 ? dir.slice(parentSlash + 1) : dir; - if (parentName) { - const initKey = `${parentName}/__init__.py`; - let ib = byInitParent.get(initKey); - if (ib === undefined) { - ib = []; - byInitParent.set(initKey, ib); - } - ib.push({ raw, norm }); - } - } - - // Directory prefixes: every slash-terminated prefix of the path (every - // index just past a '/', up to and including the file's own directory). - // Scanning the FULL normalized path — including any leading '/' for - // absolute paths — makes `dirPrefixes.has(X)` match exactly when the old - // gate's `f.startsWith(X)` (X always ends in '/') matched. The previous - // split+`filter(Boolean)` dropped the leading empty component, so an - // absolute file `/repo/svc/x.py` yielded `repo/svc/` (no leading slash) and - // gate-passed where `"/repo/svc/x.py".startsWith("repo/svc/")` is false - // (PR #1918 review P3a). For relative paths the set is identical. - for (let i = 0; i <= lastSlash; i++) { - if (norm[i] === '/') dirPrefixes.add(norm.slice(0, i + 1)); - } - } - - const index: PythonFileIndex = { normSet, byBasename, byInitParent, dirPrefixes }; - PYTHON_FILE_INDEX_CACHE.set(allFilePaths, index); - return index; -} - function pythonImportedSubmoduleTarget(parsedImport: ParsedImport): string | null { if (parsedImport.kind !== 'named' && parsedImport.kind !== 'alias') return null; if (parsedImport.targetIncludesImportedName === true) return null; diff --git a/gitnexus/src/core/ingestion/languages/python/index-stats.ts b/gitnexus/src/core/ingestion/languages/python/index-stats.ts deleted file mode 100644 index 2e3d2ae82..000000000 --- a/gitnexus/src/core/ingestion/languages/python/index-stats.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Build counter for the per-file-set Python import-resolution index - * (`getPythonFileIndex` in `import-target.ts`). - * - * A "build" is a `WeakMap` cache MISS that materializes a fresh - * `PythonFileIndex` (O(files)). Unlike `cache-stats.ts` (which gates its - * counters behind `PROF_SCOPE_RESOLUTION` because they sit on the per-capture - * hot path), this counter is always live: an index build happens at most once - * per resolution run, so the single increment is negligible and an unconditional - * counter avoids env-var load-order fragility in tests. - * - * Used by `test/integration/python-import-index-reuse.test.ts` to assert the - * index is reused across imports (built once per run) rather than rebuilt per - * import — the regression guard for PR #1918 review finding P1. - */ - -let INDEX_BUILDS = 0; - -export function recordPythonFileIndexBuild(): void { - INDEX_BUILDS++; -} - -export function getPythonFileIndexBuildCount(): number { - return INDEX_BUILDS; -} - -export function resetPythonFileIndexBuildCount(): void { - INDEX_BUILDS = 0; -} diff --git a/gitnexus/src/core/ingestion/languages/python/interpret.ts b/gitnexus/src/core/ingestion/languages/python/interpret.ts index e1a9f60ba..8c36f5c41 100644 --- a/gitnexus/src/core/ingestion/languages/python/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/python/interpret.ts @@ -21,11 +21,19 @@ export function interpretPythonImport(captures: CaptureMatch): ParsedImport | nu // `@import.name` : the imported symbol name (or module name for plain imports) // `@import.alias` : the local alias name (for `as` forms) // `@import.source`: the module path (always present except for `dynamic`) + // `@import.publishes`: present iff the statement is at module level const kindCap = captures['@import.kind']; const nameCap = captures['@import.name']; const aliasCap = captures['@import.alias']; const sourceCap = captures['@import.source']; + // Python has no dedicated re-export form: a module-level `from m import x` + // binds `x` AND publishes it as `.x`. See `reexportsName` on + // `ParsedImport` for the contract, and `import-decomposer.ts` for why the + // marker — not this function — decides whether the statement is at module + // level. + const republishes = captures['@import.publishes'] !== undefined; + const kind = kindCap?.text; if (kind === undefined) return null; @@ -58,10 +66,14 @@ export function interpretPythonImport(captures: CaptureMatch): ParsedImport | nu localName: nameCap.text, importedName: nameCap.text, targetRaw: sourceCap.text, + ...(republishes ? { reexportsName: true } : {}), }; } case 'from-alias': { - // `from m import x as y` + // `from m import x as y` — republished under the alias (`.y`). + // PEP 484 treats `import x as x` as an explicit re-export; Python's + // runtime namespace republishes every module-level form, so the flag + // follows module level rather than the redundant-alias case. if (sourceCap === undefined || nameCap === undefined || aliasCap === undefined) return null; return { kind: 'alias', @@ -69,6 +81,7 @@ export function interpretPythonImport(captures: CaptureMatch): ParsedImport | nu importedName: nameCap.text, alias: aliasCap.text, targetRaw: sourceCap.text, + ...(republishes ? { reexportsName: true } : {}), }; } case 'wildcard': { diff --git a/gitnexus/src/core/ingestion/languages/ruby/import-target.ts b/gitnexus/src/core/ingestion/languages/ruby/import-target.ts index a31f75feb..62d6fca3d 100644 --- a/gitnexus/src/core/ingestion/languages/ruby/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/ruby/import-target.ts @@ -8,7 +8,7 @@ */ import { resolveRubyImportInternal } from '../../import-resolvers/ruby.js'; -import { buildSuffixIndex } from '../../import-resolvers/utils.js'; +import { getWorkspaceFileIndex } from '../../import-resolvers/workspace-file-index.js'; import { isHeritageMarker } from '../../utils/heritage-marker.js'; export interface RubyResolveContext { @@ -100,9 +100,10 @@ function resolveRelative( * via suffix matching using the existing Ruby import resolver. */ function resolveBare(targetRaw: string, allFilePaths: ReadonlySet): string | null { - const normalizedFileList = [...allFilePaths].map((f) => f.replace(/\\/g, '/')); - const allFileList = [...allFilePaths]; - const index = buildSuffixIndex(normalizedFileList, allFileList); - - return resolveRubyImportInternal(targetRaw, normalizedFileList, allFileList, index); + // Was: two array materializations plus a full `buildSuffixIndex` per require, + // thrown away on return — every require paid to index every file in the repo + // (#2880). `buildSuffixIndex` is a pure function of the file set, so this is a + // hoist, not a behaviour change. + const { normalized, all, index } = getWorkspaceFileIndex(allFilePaths); + return resolveRubyImportInternal(targetRaw, normalized, all, index); } diff --git a/gitnexus/src/core/ingestion/languages/rust.ts b/gitnexus/src/core/ingestion/languages/rust.ts index 842da9e07..0659e3f29 100644 --- a/gitnexus/src/core/ingestion/languages/rust.ts +++ b/gitnexus/src/core/ingestion/languages/rust.ts @@ -188,6 +188,23 @@ export const rustProvider = defineLanguage({ emitScopeCaptures: emitRustScopeCaptures, cfgVisitor: createRustCfgVisitor(), interpretImport: interpretRustImport, + // `use` is a compile-time path alias, not a statement that runs. Writing one + // inside a function body — `fn f() { use crate::m::X; }`, which is legal — + // narrows where the NAME is visible and defers nothing: Rust has no + // module-initialization order in the JS/Python sense and permits intra-crate + // module cycles outright. `rust/query.ts` captures `(use_declaration)` and + // nothing else, so this covers every import form the pipeline sees; the + // structural twin is C++'s `using ns::name`, exempt under the same + // capability. Without this the central Pass-3 position rule would tag an + // fn-local `use` `runsOnlyWhenCalled` and `check --cycles` would drop a + // cycle it is part of. + // + // Deliberately the NARROW claim — position does not defer a Rust import. It + // is not a claim that no Rust import can create an initialization + // dependency; that is a bigger semantic question (statics, `OnceLock`, + // `lazy_static`) which this capability does not reach and should not be read + // as settling. See `LanguageProvider.importsExecuteWhereWritten`. + importsExecuteWhereWritten: false, interpretTypeBinding: interpretRustTypeBinding, bindingScopeFor: rustBindingScopeFor, importOwningScope: rustImportOwningScope, diff --git a/gitnexus/src/core/ingestion/languages/rust/captures.ts b/gitnexus/src/core/ingestion/languages/rust/captures.ts index ae15c99e2..d6685dde3 100644 --- a/gitnexus/src/core/ingestion/languages/rust/captures.ts +++ b/gitnexus/src/core/ingestion/languages/rust/captures.ts @@ -1,5 +1,6 @@ import type { Capture, CaptureMatch } from 'gitnexus-shared'; import { + findChild, nodeIfType, nodeToCapture, syntheticCapture, @@ -252,10 +253,22 @@ function synthesizeRustInheritanceReferences(root: SyntaxNode): CaptureMatch[] { const traitName = bareTypeIdentifier(traitField); const structName = bareTypeIdentifier(typeField); if (traitName === null || structName === null) return; + // The trait's generic ARGUMENTS (`impl Validator for V`), so + // interface dispatch can tell one instantiation of a trait from another + // (#2912). Emitted as a sub-tag rather than by widening the anchor: the + // anchor is the bare `type_identifier` inside the `generic_type`, and its + // range is part of the inheritance edge's id. + const traitArguments = + traitField.type === 'generic_type' ? findChild(traitField, 'type_arguments') : null; out.push({ '@reference.inherits': nodeToCapture('@reference.inherits', traitName), '@reference.name': nodeToCapture('@reference.name', traitName), '@reference.receiver': syntheticCapture('@reference.receiver', structName, structName.text), + ...(traitArguments === null + ? {} + : { + '@reference.type-arguments': nodeToCapture('@reference.type-arguments', traitArguments), + }), }); }); return out; diff --git a/gitnexus/src/core/ingestion/languages/rust/qualified-call.ts b/gitnexus/src/core/ingestion/languages/rust/qualified-call.ts index 65d1dd5f4..54047c6ff 100644 --- a/gitnexus/src/core/ingestion/languages/rust/qualified-call.ts +++ b/gitnexus/src/core/ingestion/languages/rust/qualified-call.ts @@ -33,6 +33,7 @@ */ import type { ParsedFile, Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared'; +import { perFileSet } from '../../import-resolvers/per-file-set.js'; import { isOverloadableCallable } from '../../utils/callable-labels.js'; import { lookupBindingsAt } from '../../scope-resolution/scope/walkers.js'; import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; @@ -53,16 +54,9 @@ import { * The hook is invoked per call site; rebuilding the index each time would make * qualified-call resolution O(sites x files). */ -const MODULE_INDEX_CACHE = new WeakMap, RustModuleIndex>(); - -function moduleIndexFor(allFilePaths: ReadonlySet): RustModuleIndex { - let index = MODULE_INDEX_CACHE.get(allFilePaths); - if (index === undefined) { - index = buildRustModuleIndex(allFilePaths); - MODULE_INDEX_CACHE.set(allFilePaths, index); - } - return index; -} +const moduleIndexFor = perFileSet( + (allFilePaths: ReadonlySet): RustModuleIndex => buildRustModuleIndex(allFilePaths), +); export function resolveRustQualifiedFreeCall( site: { readonly name: string; readonly rawQualifiedName?: string; readonly inScope: ScopeId }, @@ -488,6 +482,15 @@ interface PassModuleIndex { readonly inlineModuleKeys: ReadonlySet; } +/** + * DELIBERATELY NOT ON `import-resolvers/per-file-set.ts` (#2909 sweep), unlike + * {@link moduleIndexFor} above. {@link passIndexFor} takes THREE inputs — + * `workspaceIndex`, `index` and `scopes` — and keys on the first alone; the + * builder reads `scopes.defs.byId` and `index`, neither of which is derivable + * from the key, and `perFileSet`'s `build: (key) => T` hands the builder + * nothing but the key. Sound here only because all three share the resolution + * pass's lifetime, which is an invariant the primitive cannot express. + */ const MODULE_SCOPE_CACHE = new WeakMap(); function moduleKey(module: RustModule): string { diff --git a/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts index 5fd0f1570..bea53d9fd 100644 --- a/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts @@ -16,6 +16,7 @@ import { import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; import { resolveDefGraphId } from '../../scope-resolution/graph-bridge/ids.js'; import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js'; +import type { HeritageTypeArgumentSink } from '../../scope-resolution/utils/generic-instantiation.js'; import type { KnowledgeGraph } from '../../../graph/types.js'; import { generateId } from '../../../../lib/utils.js'; @@ -54,6 +55,7 @@ function emitRustTraitImplEdges( parsedFiles: readonly ParsedFile[], nodeLookup: GraphNodeLookup, scopes: ScopeResolutionIndexes | undefined, + recordTypeArguments?: HeritageTypeArgumentSink, ): void { if (scopes === undefined) return; @@ -83,6 +85,14 @@ function emitRustTraitImplEdges( const traitGraphId = resolveDefGraphId(traitDef.filePath, traitDef, nodeLookup); if (structGraphId === undefined || traitGraphId === undefined) continue; + // The instantiation the impl was written with — `impl Validator + // for V` (#2912). Recorded against THIS edge's ids, not the pre-pass's: + // the pre-pass sources its edge from the enclosing def, and interface + // dispatch crosses the corrected one emitted here. + if (site.typeArguments !== undefined) { + recordTypeArguments?.(structGraphId, traitGraphId, site.typeArguments); + } + const edgeKey = `${structGraphId}->${traitGraphId}`; if (emitted.has(edgeKey)) continue; emitted.add(edgeKey); @@ -159,8 +169,8 @@ export const rustScopeResolver: ScopeResolver = { buildMro: (graph, parsedFiles, nodeLookup) => buildRustMro(graph, parsedFiles, nodeLookup), - emitHeritageEdges: (graph, parsedFiles, nodeLookup, scopes) => - emitRustTraitImplEdges(graph, parsedFiles, nodeLookup, scopes), + emitHeritageEdges: (graph, parsedFiles, nodeLookup, scopes, recordTypeArguments) => + emitRustTraitImplEdges(graph, parsedFiles, nodeLookup, scopes, recordTypeArguments), populateOwners: (parsed: ParsedFile) => populateRustOwners(parsed), diff --git a/gitnexus/src/core/ingestion/languages/swift/import-target.ts b/gitnexus/src/core/ingestion/languages/swift/import-target.ts index 5c0c2f662..e0d5e8219 100644 --- a/gitnexus/src/core/ingestion/languages/swift/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/swift/import-target.ts @@ -25,6 +25,7 @@ */ import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared'; +import { perFileSet } from '../../import-resolvers/per-file-set.js'; export interface SwiftResolveContext { readonly fromFile: string; @@ -39,12 +40,7 @@ interface SwiftModuleIndex { readonly byModule: Map; } -const SWIFT_MODULE_INDEX_CACHE = new WeakMap, SwiftModuleIndex>(); - -function getSwiftModuleIndex(allFilePaths: ReadonlySet): SwiftModuleIndex { - const cached = SWIFT_MODULE_INDEX_CACHE.get(allFilePaths); - if (cached !== undefined) return cached; - +const getSwiftModuleIndex = perFileSet((allFilePaths: ReadonlySet): SwiftModuleIndex => { const byModule = new Map(); for (const raw of allFilePaths) { const norm = raw.replace(/\\/g, '/'); @@ -66,10 +62,8 @@ function getSwiftModuleIndex(allFilePaths: ReadonlySet): SwiftModuleInde } } - const index: SwiftModuleIndex = { byModule }; - SWIFT_MODULE_INDEX_CACHE.set(allFilePaths, index); - return index; -} + return { byModule }; +}); export function resolveSwiftImportTarget( parsedImport: ParsedImport, diff --git a/gitnexus/src/core/ingestion/languages/typescript.ts b/gitnexus/src/core/ingestion/languages/typescript.ts index c24ab6ae8..4cf95f9c9 100644 --- a/gitnexus/src/core/ingestion/languages/typescript.ts +++ b/gitnexus/src/core/ingestion/languages/typescript.ts @@ -125,6 +125,15 @@ import { jsArityCompatibility, } 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) => [ + ...extractDispatchGuardRoutes(...args), + ...extractDataRouteTableRoutes(...args), + ...extractNestRoutes(...args), +]; /** * TypeScript/JavaScript: arrow_function and function_expression are @@ -412,6 +421,7 @@ export const typescriptProvider = defineLanguage({ extractFunctionName: tsExtractFunctionName, }), variableExtractor: createVariableExtractor(typescriptVariableConfig), + definitionPropertiesExtractor: extractConvexEndpointProperties, classExtractor: createClassExtractor(typescriptClassConfig), // ── JSDoc → description (issue #2270). An exported decl is captured as the // inner declaration; its JSDoc precedes the wrapping `export_statement`. ── @@ -458,12 +468,12 @@ export const typescriptProvider = defineLanguage({ // A raw `node:http` server declares its routes by comparing the request path // to a literal; nothing else in this pipeline can see that shape. TS and JS // share the grammar, so they share the extractor. - extractDecoratorRoutes: extractDispatchGuardRoutes, + extractDecoratorRoutes: extractJsTsRoutes, }); export const javascriptProvider = defineLanguage({ id: SupportedLanguages.JavaScript, - extensions: ['.js', '.jsx'], + extensions: ['.js', '.jsx', '.mjs', '.cjs'], entryPointPatterns: [/^use[A-Z]/], astFrameworkPatterns: [ { @@ -499,6 +509,7 @@ export const javascriptProvider = defineLanguage({ extractFunctionName: tsExtractFunctionName, }), variableExtractor: createVariableExtractor(javascriptVariableConfig), + definitionPropertiesExtractor: extractConvexEndpointProperties, classExtractor: createClassExtractor(javascriptClassConfig), // ── JSDoc → description (issue #2270). An exported decl is captured as the // inner declaration; its JSDoc precedes the wrapping `export_statement`. ── @@ -532,5 +543,5 @@ export const javascriptProvider = defineLanguage({ receiverBinding: jsReceiverBinding, arityCompatibility: jsArityCompatibility, // See the TypeScript provider above. - extractDecoratorRoutes: extractDispatchGuardRoutes, + extractDecoratorRoutes: extractJsTsRoutes, }); diff --git a/gitnexus/src/core/ingestion/languages/typescript/captures.ts b/gitnexus/src/core/ingestion/languages/typescript/captures.ts index b3d9315ee..2e3a76290 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/captures.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/captures.ts @@ -6,7 +6,7 @@ * synthesized streams on top: * * 1. **Import decomposition** — each `import_statement` / re-export is - * re-emitted with `@import.kind/source/name/alias/typeOnly` markers so + * re-emitted with `@import.kind/source/name/alias/type-only` markers so * `interpretTsImport` can recover the `ParsedImport` shape without * re-parsing raw text (see `import-decomposer.ts`). Unit 2 adds this; * until then, raw `@import.statement` matches flow through as-is. diff --git a/gitnexus/src/core/ingestion/languages/typescript/convex-endpoint-metadata.ts b/gitnexus/src/core/ingestion/languages/typescript/convex-endpoint-metadata.ts new file mode 100644 index 000000000..b31a079d1 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/typescript/convex-endpoint-metadata.ts @@ -0,0 +1,113 @@ +import type { ParsedImport } from 'gitnexus-shared'; +import type { DefinitionPropertiesContext } from '../../language-provider.js'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; +import { assertCloneable } from '../../workers/clone-safety.js'; + +const GENERATED_ENDPOINT_FACTORIES: ReadonlySet = new Set([ + 'query', + 'mutation', + 'action', + 'internalQuery', + 'internalMutation', + 'internalAction', + 'httpAction', +]); + +const GENERIC_ENDPOINT_FACTORIES: ReadonlyMap = new Map( + [...GENERATED_ENDPOINT_FACTORIES].map((factory) => [`${factory}Generic`, factory]), +); + +const normalizeModuleTarget = (targetRaw: string): string => + targetRaw.replace(/\\/g, '/').replace(/\.(?:[cm]?[jt]s)$/, ''); + +const isGeneratedServerModule = (targetRaw: string): boolean => + /(?:^|\/)_generated\/server$/.test(normalizeModuleTarget(targetRaw)); + +function importedConvexFactory( + imports: readonly ParsedImport[], + localName: string, +): string | undefined { + for (const parsedImport of imports) { + if (parsedImport.kind !== 'named' && parsedImport.kind !== 'alias') continue; + if (parsedImport.localName !== localName) continue; + + const target = normalizeModuleTarget(parsedImport.targetRaw); + if (target === 'convex/server') { + return GENERIC_ENDPOINT_FACTORIES.get(parsedImport.importedName); + } + if (isGeneratedServerModule(target)) { + return GENERATED_ENDPOINT_FACTORIES.has(parsedImport.importedName) + ? parsedImport.importedName + : undefined; + } + } + return undefined; +} + +function matchingDeclarator(node: SyntaxNode, nodeName: string): SyntaxNode | undefined { + if (node.type === 'variable_declarator' && node.childForFieldName('name')?.text === nodeName) { + return node; + } + + if (node.type === 'export_statement') { + const declaration = node.childForFieldName('declaration'); + return declaration ? matchingDeclarator(declaration, nodeName) : undefined; + } + if (node.type !== 'lexical_declaration' && node.type !== 'variable_declaration') { + return undefined; + } + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if ( + child?.type === 'variable_declarator' && + child.childForFieldName('name')?.text === nodeName + ) { + return child; + } + } + return undefined; +} + +function findDeclarator(node: SyntaxNode, nodeName: string): SyntaxNode | undefined { + let current: SyntaxNode | null = node; + while (current) { + const declarator = matchingDeclarator(current, nodeName); + if (declarator) return declarator; + if (current.type === 'program' || current.type === 'statement_block') break; + current = current.parent; + } + return undefined; +} + +/** + * Stamp Convex runtime-dispatch metadata only when both the declaration shape + * and the factory import provenance are known. The MCP layer consumes the + * resulting property without reparsing lossy FTS text. + */ +export function extractConvexEndpointProperties( + context: DefinitionPropertiesContext, +): Readonly> | undefined { + if ((context.nodeLabel !== 'Const' && context.nodeLabel !== 'Function') || !context.isExported) { + return undefined; + } + + const declarator = findDeclarator(context.definitionNode, context.nodeName); + const value = declarator?.childForFieldName('value'); + if (!value || value.type !== 'call_expression') return undefined; + + const callee = value.childForFieldName('function'); + if (!callee || callee.type !== 'identifier') return undefined; + const factory = importedConvexFactory(context.parsedImports, callee.text); + if (factory === undefined) return undefined; + + const args = value.childForFieldName('arguments'); + if (!args || args.namedChildCount !== 1) return undefined; + const endpointDefinition = args.namedChild(0); + if ( + !endpointDefinition || + !['object', 'arrow_function', 'function_expression'].includes(endpointDefinition.type) + ) { + return undefined; + } + return assertCloneable({ convexEndpointFactory: factory }); +} diff --git a/gitnexus/src/core/ingestion/languages/typescript/file-candidates.ts b/gitnexus/src/core/ingestion/languages/typescript/file-candidates.ts new file mode 100644 index 000000000..421959788 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/typescript/file-candidates.ts @@ -0,0 +1,70 @@ +/** + * Turning a resolved stem into a real file, the way TypeScript does (#2953). + * + * Shared by `module-resolution.ts` and the package-manifest resolver so both + * try the same three shapes — exact path, extension, directory index — and, as + * importantly, the same NARROW extension list. The repo-wide `EXTENSIONS` in + * `import-resolvers/utils.ts` carries ~39 entries spanning every language the + * indexer supports; a TypeScript import cannot resolve to a `.py` or `.rb` + * file, and letting it try was part of how the old suffix matcher found files + * that had nothing to do with the import. + */ + +/** Extension candidates, in the order TypeScript tries them. */ +export const TS_EXTENSIONS = [ + '.ts', + '.tsx', + '.d.ts', + '.mts', + '.cts', + '.js', + '.jsx', + '.mjs', + '.cjs', + '.vue', + '.json', +] as const; + +/** + * JS-family extensions a specifier may carry for a TypeScript source file. + * + * TypeScript ESM requires the specifier to name the EMITTED file (`./m.js`) + * while the file on disk is `./m.ts`, so a resolver that only tried the literal + * extension would miss every ESM-style relative import in a modern codebase. + */ +export const JS_TO_TS: ReadonlyMap = new Map([ + ['.js', ['.ts', '.tsx', '.d.ts']], + ['.jsx', ['.tsx']], + ['.mjs', ['.mts']], + ['.cjs', ['.cts']], +]); + +/** + * A repo-relative stem resolved to a real indexed file, or `null`. + * + * Exact match, then the ESM `.js` → `.ts` rewrite, then each extension, then + * the directory-index form. Nothing here searches: every candidate is derived + * from the stem the caller already resolved from a declared source. + */ +export function resolveFile(stem: string, allFiles: ReadonlySet): string | null { + if (stem === '') return null; + if (allFiles.has(stem)) return stem; + + const dot = stem.lastIndexOf('.'); + const ext = dot === -1 ? '' : stem.slice(dot); + const tsEquivalents = JS_TO_TS.get(ext); + if (tsEquivalents !== undefined) { + const stripped = stem.slice(0, -ext.length); + for (const candidate of tsEquivalents) { + if (allFiles.has(stripped + candidate)) return stripped + candidate; + } + } + + for (const candidate of TS_EXTENSIONS) { + if (allFiles.has(stem + candidate)) return stem + candidate; + } + for (const candidate of TS_EXTENSIONS) { + if (allFiles.has(`${stem}/index${candidate}`)) return `${stem}/index${candidate}`; + } + return null; +} diff --git a/gitnexus/src/core/ingestion/languages/typescript/import-decomposer.ts b/gitnexus/src/core/ingestion/languages/typescript/import-decomposer.ts index babcb45da..c79046026 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/import-decomposer.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/import-decomposer.ts @@ -29,7 +29,34 @@ * Type-only constructs (`import type { X }`, `import { type X }`, * `export type { X }`) emit the same kinds as runtime forms — at the * TypeScript scope-resolution layer, types and values share the same - * lookup; runtime-emission is a downstream concern. + * lookup, so the KIND is unchanged. They additionally carry an + * `@import.type-only` marker, because `tsc` deletes them: no `require` / + * `import` for the source module survives in the emitted JavaScript, so + * the pair cannot force a module-initialization order. `check --cycles` + * is the consumer — see `graph-bridge/imports-to-edges.ts`. + * + * Both spellings put the `type` keyword in a different place, so both are + * read (see `hasTypeKeyword`): + * + * import type { X, Y } from './m' — anonymous `type` token on the + * `import_statement`, covering EVERY + * specifier it decomposes to + * import { type X, Y } from './m' — anonymous `type` token on the + * `import_specifier`, covering only X + * + * The marker is therefore per-specifier, which is what makes the mixed + * statement come out right: `X` is erased, `Y` is not, and the pair + * `./m` is a real initialization dependency because of `Y`. Emission + * dedupes per `(sourceFile, targetFile)` and lets any non-erased edge win, + * so a statement counts as type-only exactly when every specifier it + * decomposes to is — without this file having to aggregate anything. + * + * Known gap: TypeScript 5.0's `export type * from './m'` / `export type * + * as ns from './m'`. The vendored grammar does not parse them — the bare + * `type` token lands in an `ERROR` node beside the `*`, not as a statement + * child — so they emit no marker and are treated as value imports. That is + * the fail-safe direction (`check --cycles` over-reports rather than + * hiding a real cycle), and neither form appears in this repository. * * Side-effect imports (`import './polyfill'`) produce a single match * with `kind: 'side-effect'`. The shared finalize algorithm resolves @@ -73,6 +100,66 @@ interface ImportSpec { /** Set on `dynamic` kind imports when the argument is a string literal — * enables `interpretTsImport` to emit `dynamic-resolved`. */ readonly literalSource?: boolean; + /** This specifier is erased by `tsc` (`import type` / `{ type X }`) — + * enables `interpretTsImport` to set `ParsedImport.typeOnly`. */ + readonly typeOnly?: boolean; +} + +/** + * Cheap prefilter for {@link hasTypeKeyword}. + * + * The keyword's text is exactly `type`, and every specifier lies inside its + * statement's text, so a statement whose text holds no `type` substring + * anywhere cannot carry the token at either level. Sound in one direction only, + * which is the direction that matters: it can admit a statement that turns out + * to have no keyword (`import { getType }`), never reject one that has it. + * + * Worth the extra test because the two are not the same order of cost. + * `node.text` is one slice; {@link hasTypeKeyword} crosses the N-API boundary + * and allocates a node wrapper once per direct child, and it runs per statement + * AND per specifier — so a statement of N specifiers pays N+1 walks. + * + * It pays most where there is nothing to find, and that case is not rare: + * `javascript/captures.ts` shares this decomposer, JavaScript has no + * `import type` at all, and no `.js` import can contain the token — so every + * JavaScript file was paying the full walk, never early-exiting, for an answer + * that is structurally always `false`. + */ +function mayHaveTypeKeyword(stmtNode: SyntaxNode): boolean { + return stmtNode.text.includes('type'); +} + +/** + * Does this node carry the `type` keyword that erases the import? + * + * The keyword is an ANONYMOUS token, so `findChild` (named children only) + * cannot see it and the direct child list has to be walked. Two nodes are + * ever asked: + * + * - `import_statement` / `export_statement` — `import type { X } from './m'` + * - `import_specifier` / `export_specifier` — `import { type X } from './m'` + * + * Only DIRECT children are considered. A nested `type` token means something + * else entirely — `export type Foo = Bar` puts one inside the child + * `type_alias_declaration` — and a subtree scan would read those as erasure. + * No NAMED node in this grammar is called `type`, so matching the type name + * alone identifies the keyword without asking about `isNamed`, whose spelling + * differs between tree-sitter bindings. + * + * `field-extractors/configs/helpers.ts`'s `hasKeyword` walks the same direct + * children and must NOT be reused here, for a sharper reason than the walk: it + * matches on `child.text.trim()`, not on the node type. `import type from './m'` + * is a DEFAULT import binding the name `type`, and its `import_clause`'s whole + * text is `type` — so `hasKeyword` reports erasure for an import that really + * runs, and the pair would be dropped from cycle reporting. Matching the token's + * TYPE is what separates the keyword from an identifier that happens to spell + * it. + */ +function hasTypeKeyword(node: SyntaxNode): boolean { + for (let i = 0; i < node.childCount; i++) { + if (node.child(i)?.type === 'type') return true; + } + return false; } /** @@ -117,6 +204,13 @@ function splitImport(stmtNode: SyntaxNode): CaptureMatch[] { ]; } + // `import type ...` erases every specifier in the statement. `import + // { type X, Y }` erases only the marked ones, which is read per specifier + // in `decomposeNamedSpecifier`. Default and namespace forms have no + // per-specifier spelling, so the statement keyword is all there is. + const mayHaveType = mayHaveTypeKeyword(stmtNode); + const statementTypeOnly = mayHaveType && hasTypeKeyword(stmtNode); + const out: CaptureMatch[] = []; // An import_clause can have any combination of: // - leading identifier (default import) @@ -135,6 +229,7 @@ function splitImport(stmtNode: SyntaxNode): CaptureMatch[] { name: 'default', alias: child.text, atNode: child, + typeOnly: statementTypeOnly, }), ); continue; @@ -151,6 +246,7 @@ function splitImport(stmtNode: SyntaxNode): CaptureMatch[] { name: source, alias: aliasId.text, atNode: child, + typeOnly: statementTypeOnly, }), ); } @@ -161,14 +257,20 @@ function splitImport(stmtNode: SyntaxNode): CaptureMatch[] { for (let j = 0; j < child.namedChildCount; j++) { const spec = child.namedChild(j); if (spec === null || spec.type !== 'import_specifier') continue; - const decomposed = decomposeNamedSpecifier(spec, source, stmtNode); + const decomposed = decomposeNamedSpecifier( + spec, + source, + stmtNode, + statementTypeOnly, + mayHaveType, + ); if (decomposed !== null) out.push(decomposed); } continue; } - // Other children (e.g. `type` keyword token for `import type { ... }`) - // are ignored — they carry no per-specifier info; we fold type-only - // semantics into the same emitted kinds. + // No other named children exist on an `import_clause`. The `type` + // keyword of `import type { ... }` is an ANONYMOUS token on the + // statement, not a clause child, and is read by `hasTypeKeyword` above. } return out; @@ -179,13 +281,20 @@ function splitImport(stmtNode: SyntaxNode): CaptureMatch[] { * * - `{ X }` → named * - `{ X as Y }` → named-alias - * - `{ type X }` → named (type-only; same shape) - * - `{ type X as Y }` → named-alias (type-only) + * - `{ type X }` → named (+ `@import.type-only`) + * - `{ type X as Y }` → named-alias (+ `@import.type-only`) + * + * `statementTypeOnly` is the `import type { … }` form, which erases this + * specifier regardless of what the specifier itself spells; the two are + * ORed rather than one overriding the other, because `import type { type X }` + * is legal-ish input and both spellings mean the same erasure. */ function decomposeNamedSpecifier( spec: SyntaxNode, source: string, stmtNode: SyntaxNode, + statementTypeOnly: boolean, + mayHaveType: boolean, ): CaptureMatch | null { // `import_specifier` layout: // name: identifier @@ -199,6 +308,7 @@ function decomposeNamedSpecifier( const aliasNode = spec.childForFieldName('alias'); if (nameNode === null) return null; const name = nameNode.text; + const typeOnly = statementTypeOnly || (mayHaveType && hasTypeKeyword(spec)); if (aliasNode !== null && aliasNode.startIndex !== nameNode.startIndex) { return buildImportMatch(stmtNode, { @@ -207,6 +317,7 @@ function decomposeNamedSpecifier( name, alias: aliasNode.text, atNode: spec, + typeOnly, }); } return buildImportMatch(stmtNode, { @@ -214,6 +325,7 @@ function decomposeNamedSpecifier( source, name, atNode: spec, + typeOnly, }); } @@ -234,13 +346,24 @@ function splitReexport(stmtNode: SyntaxNode): CaptureMatch[] { const source = extractSource(stmtNode); if (source === null) return []; + // `export type { X } from './m'`. Its `export type *` sibling is NOT + // detectable — see the known gap in the module header. + const mayHaveType = mayHaveTypeKeyword(stmtNode); + const statementTypeOnly = mayHaveType && hasTypeKeyword(stmtNode); + const exportClause = findChild(stmtNode, 'export_clause'); if (exportClause !== null) { const out: CaptureMatch[] = []; for (let i = 0; i < exportClause.namedChildCount; i++) { const spec = exportClause.namedChild(i); if (spec === null || spec.type !== 'export_specifier') continue; - const decomposed = decomposeReexportSpecifier(spec, source, stmtNode); + const decomposed = decomposeReexportSpecifier( + spec, + source, + stmtNode, + statementTypeOnly, + mayHaveType, + ); if (decomposed !== null) out.push(decomposed); } return out; @@ -273,6 +396,7 @@ function splitReexport(stmtNode: SyntaxNode): CaptureMatch[] { name: source, alias: aliasId.text, atNode: namespaceExport, + typeOnly: statementTypeOnly, }), buildNamespaceDeclarationMatch(namespaceExport, aliasId), ]; @@ -292,15 +416,20 @@ function splitReexport(stmtNode: SyntaxNode): CaptureMatch[] { ]; } +/** Mirror of {@link decomposeNamedSpecifier} for `export { … } from './m'`, + * including the per-specifier `export { type X } from './m'` spelling. */ function decomposeReexportSpecifier( spec: SyntaxNode, source: string, stmtNode: SyntaxNode, + statementTypeOnly: boolean, + mayHaveType: boolean, ): CaptureMatch | null { const nameNode = spec.childForFieldName('name'); const aliasNode = spec.childForFieldName('alias'); if (nameNode === null) return null; const name = nameNode.text; + const typeOnly = statementTypeOnly || (mayHaveType && hasTypeKeyword(spec)); if (aliasNode !== null && aliasNode.startIndex !== nameNode.startIndex) { return buildImportMatch(stmtNode, { @@ -309,6 +438,7 @@ function decomposeReexportSpecifier( name, alias: aliasNode.text, atNode: spec, + typeOnly, }); } return buildImportMatch(stmtNode, { @@ -316,6 +446,7 @@ function decomposeReexportSpecifier( source, name, atNode: spec, + typeOnly, }); } @@ -420,6 +551,12 @@ function buildImportMatch(stmtNode: SyntaxNode, spec: ImportSpec): CaptureMatch if (spec.literalSource === true) { m['@import.literal'] = syntheticCapture('@import.literal', spec.atNode, ''); } + // Presence-only, like `@import.literal`: absent means "not erased", so the + // marker is added rather than spelled `'false'`, and every non-TypeScript + // provider's matches keep the shape they already have. + if (spec.typeOnly === true) { + m['@import.type-only'] = syntheticCapture('@import.type-only', spec.atNode, ''); + } return m; } diff --git a/gitnexus/src/core/ingestion/languages/typescript/import-target.ts b/gitnexus/src/core/ingestion/languages/typescript/import-target.ts index 7d39e1f64..2100a7717 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/import-target.ts @@ -1,44 +1,35 @@ /** * Adapter from `(ParsedImport, WorkspaceIndex)` → concrete file path. * - * Delegates to the existing standard-strategy resolver - * (`resolveImportPath`) so tsconfig path aliases (`@/`, `~/`, …) and - * suffix-based resolution follow the same rules as the legacy path. + * Delegates to `module-resolution.ts`, which runs the algorithm `tsc` and Node + * actually run. It used to delegate to the shared `resolveImportPath`, whose + * final step was `suffixResolve` — a repo-wide search for any file path ending + * in the specifier. That is what #2953 removed: this path now resolves only + * against declared inputs (real paths, tsconfig `paths`/`baseUrl`, package + * manifests) and answers `null` for everything else. * - * The `WorkspaceIndex` is opaque at the shared contract layer; we - * narrow it to a TypeScript-shaped context that carries `fromFile` + - * the full `allFilePaths` set + the optional `tsconfigPaths` the - * resolver reads. + * The `WorkspaceIndex` is opaque at the shared contract layer; we narrow it to + * a TypeScript-shaped context carrying `fromFile`, the workspace file set, and + * the two config indexes the algorithm reads. * * Returning `null` lets the finalize algorithm mark the edge as - * `linkStatus: 'unresolved'`. + * `linkStatus: 'unresolved'` — which for an external package is the correct + * and complete answer. */ import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared'; -import { SupportedLanguages } from 'gitnexus-shared'; -import { resolveImportPath } from '../../import-resolvers/standard.js'; -import type { SuffixIndex } from '../../import-resolvers/utils.js'; -import type { TsconfigPaths } from '../../language-config.js'; +import type { NodeWorkspacePackages } from '../../import-resolvers/node-workspace-packages.js'; +import { resolveTsModule } from './module-resolution.js'; +import type { TsconfigIndex } from './tsconfig.js'; export interface TsResolveContext { readonly fromFile: string; - /** Mutable `Set` because the standard resolver consumes `Set`. - * Callers holding a `ReadonlySet` should copy via `new Set(...)`. */ - readonly allFilePaths: Set; - /** Repo file list, normalized (lowercased) for suffix matching. May - * be supplied by the orchestrator; if absent we derive it on the - * fly from `allFilePaths`. */ - readonly allFileList?: readonly string[]; - readonly normalizedFileList?: readonly string[]; - /** Per-call resolution cache to dedupe repeated lookups. */ - readonly resolveCache?: Map; - /** Prebuilt suffix index for O(1)-style package/absolute import matching. */ - readonly index?: SuffixIndex; - /** Parsed tsconfig path-aliases. `null` = no aliases configured. */ - readonly tsconfigPaths?: TsconfigPaths | null; - /** JavaScript vs TypeScript switch — affects the extensions the - * resolver tries. Defaults to TypeScript. */ - readonly language?: SupportedLanguages.TypeScript | SupportedLanguages.JavaScript; + /** The workspace file set. */ + readonly allFilePaths: ReadonlySet; + /** Every tsconfig in the repo; `null` when the repo declares none. */ + readonly tsconfigs?: TsconfigIndex | null; + /** Every in-repo `package.json`; `null` when the repo declares none. */ + readonly nodeWorkspacePackages?: NodeWorkspacePackages | null; } export function resolveTsImportTarget( @@ -59,36 +50,21 @@ export function resolveTsImportTarget( } /** - * Resolve a raw module-path string to a workspace file path using the - * same standard-strategy resolver as the legacy DAG. Operates directly on - * the source string without requiring a `ParsedImport`, so the - * `ScopeResolver.resolveImportTarget` adapter doesn't need to construct - * a fake `ParsedImport` to reach the resolver. + * Resolve a raw module-path string to a workspace file path. Operates directly + * on the source string without requiring a `ParsedImport`, so the + * `ScopeResolver.resolveImportTarget` adapter doesn't need to construct a fake + * one to reach the resolver. * - * Returns `null` when: - * - the context is malformed (missing `fromFile` / `allFilePaths`) - * - `targetRaw` is empty - * - the resolver finds no matching file + * Returns `null` when `targetRaw` is empty, names an external package, or names + * something no declared config maps into the repo. */ export function resolveTsTarget(targetRaw: string, ctx: TsResolveContext): string | null { - if (targetRaw === '') return null; - - const language = ctx.language ?? SupportedLanguages.TypeScript; - const allFileList = ctx.allFileList ?? Array.from(ctx.allFilePaths); - const normalizedFileList = ctx.normalizedFileList ?? allFileList.map((f) => f.toLowerCase()); - const resolveCache = ctx.resolveCache ?? new Map(); - - return resolveImportPath( - ctx.fromFile, - targetRaw, - ctx.allFilePaths, - allFileList as string[], - normalizedFileList as string[], - resolveCache, - language, - ctx.tsconfigPaths ?? null, - ctx.index, - ); + return resolveTsModule(targetRaw, { + fromFile: ctx.fromFile, + allFilePaths: ctx.allFilePaths, + tsconfigs: ctx.tsconfigs ?? null, + workspacePackages: ctx.nodeWorkspacePackages ?? null, + }); } function narrowTsContext(workspaceIndex: WorkspaceIndex): TsResolveContext | null { diff --git a/gitnexus/src/core/ingestion/languages/typescript/interpret.ts b/gitnexus/src/core/ingestion/languages/typescript/interpret.ts index d4f15ef9f..30236f95f 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/interpret.ts @@ -8,7 +8,8 @@ * * The import matches arrive pre-decomposed by `emitTsScopeCaptures` * (one imported name per match, with synthesized - * `@import.kind/source/name/alias` markers — see `import-decomposer.ts`). + * `@import.kind/source/name/alias/type-only` markers — see + * `import-decomposer.ts`). * The type-binding matches arrive straight from the raw query captures — * each `@type-binding.*` anchor carries `@type-binding.name` + * `@type-binding.type`. @@ -16,14 +17,18 @@ import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'gitnexus-shared'; +/** Shared empty result for the non-type-only path — see `typeOnly` below. */ +const NO_TYPE_ONLY: { typeOnly?: true } = Object.freeze({}); + // ─── interpretImport ────────────────────────────────────────────────────── export function interpretTsImport(captures: CaptureMatch): ParsedImport | null { // Markers attached by `splitImportStatement` (import-decomposer.ts): - // @import.kind : one of the kinds documented there - // @import.name : imported name from the source module - // @import.alias : local alias name (for default / aliased / namespace forms) - // @import.source : module path (always present except dynamic-unresolved) + // @import.kind : one of the kinds documented there + // @import.name : imported name from the source module + // @import.alias : local alias name (for default / aliased / namespace forms) + // @import.source : module path (always present except dynamic-unresolved) + // @import.type-only : presence-only — this specifier is erased by `tsc` const kindCap = captures['@import.kind']; const nameCap = captures['@import.name']; const aliasCap = captures['@import.alias']; @@ -32,6 +37,15 @@ export function interpretTsImport(captures: CaptureMatch): ParsedImport | null { const kind = kindCap?.text; if (kind === undefined) return null; + // Spread rather than `typeOnly: ` so a value import keeps the exact + // object shape it had before this marker existed — every `ParsedImport` + // equality assertion in the suite compares whole objects. + // `NO_TYPE_ONLY` is shared rather than a fresh `{}` per import: the spread + // reads it and never retains it, and the ~99% of imports that are not + // type-only would otherwise each allocate an object to contribute nothing. + const typeOnly: { typeOnly?: true } = + captures['@import.type-only'] !== undefined ? { typeOnly: true } : NO_TYPE_ONLY; + switch (kind) { case 'default': { // `import D from './m'` — semantically "alias for the module's @@ -45,6 +59,7 @@ export function interpretTsImport(captures: CaptureMatch): ParsedImport | null { importedName: 'default', alias: aliasCap.text, targetRaw: sourceCap.text, + ...typeOnly, }; } case 'named': { @@ -55,6 +70,7 @@ export function interpretTsImport(captures: CaptureMatch): ParsedImport | null { localName: nameCap.text, importedName: nameCap.text, targetRaw: sourceCap.text, + ...typeOnly, }; } case 'named-alias': { @@ -68,6 +84,7 @@ export function interpretTsImport(captures: CaptureMatch): ParsedImport | null { importedName: nameCap.text, alias: aliasCap.text, targetRaw: sourceCap.text, + ...typeOnly, }; } case 'namespace': { @@ -78,6 +95,7 @@ export function interpretTsImport(captures: CaptureMatch): ParsedImport | null { localName: aliasCap.text, importedName: sourceCap.text, targetRaw: sourceCap.text, + ...typeOnly, }; } case 'reexport': { @@ -88,6 +106,7 @@ export function interpretTsImport(captures: CaptureMatch): ParsedImport | null { localName: nameCap.text, importedName: nameCap.text, targetRaw: sourceCap.text, + ...typeOnly, }; } case 'reexport-alias': { @@ -101,6 +120,7 @@ export function interpretTsImport(captures: CaptureMatch): ParsedImport | null { importedName: nameCap.text, alias: aliasCap.text, targetRaw: sourceCap.text, + ...typeOnly, }; } case 'reexport-wildcard': { @@ -119,6 +139,7 @@ export function interpretTsImport(captures: CaptureMatch): ParsedImport | null { localName: aliasCap.text, importedName: sourceCap.text, targetRaw: sourceCap.text, + ...typeOnly, }; } case 'dynamic': { diff --git a/gitnexus/src/core/ingestion/languages/typescript/module-resolution.ts b/gitnexus/src/core/ingestion/languages/typescript/module-resolution.ts new file mode 100644 index 000000000..ea8d2d637 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/typescript/module-resolution.ts @@ -0,0 +1,199 @@ +/** + * TypeScript / JavaScript module resolution (#2953). + * + * This is the algorithm `tsc` and Node actually run, in the order they run it. + * It replaces `import-resolvers/utils.ts:suffixResolve` on the TS/JS/Vue path, + * which answered a different question — "does any file in this repo have a path + * ending in this specifier?" — and answered it by dropping leading segments + * until something matched. That is why `@acme/telemetry/nest`, a registry + * dependency, landed on the repo's only path ending in `nest/index.ts`. + * + * Every rule below resolves against something DECLARED: a real path, a + * `tsconfig` mapping, or a `package.json` manifest. A specifier that matches + * none of them is external, and external resolves to nothing. There is + * deliberately no fallback: a guess is what this module exists to remove, and + * an edge nobody declared is worse than a missing one precisely because it + * cannot be told apart from a real one downstream. + * + * ## The order, and why it is this order + * + * 1. relative / absolute — a path is a path; nothing else can claim it. + * 2. `#`-prefixed — package.json `imports`, which is scoped to the importing + * package and shadows everything else by design. + * 3. tsconfig `paths` — explicit mappings win over `baseUrl`, and the LONGEST + * matching pattern wins among them (tsc's rule, not first-declared). + * 4. tsconfig `baseUrl` — the rule that makes `import 'src/utils/foo'` legal. + * Note it applies only when a config actually declares one; without it, + * TypeScript treats a non-relative specifier as a package lookup, and so + * does this module. + * 5. workspace package — the manifest map, resolved through that package's + * own `exports` / `main` / `module` / `types`. + * 6. anything else — external. `null`. + */ + +import type { NodeWorkspacePackages } from '../../import-resolvers/node-workspace-packages.js'; +import { + matchSubpathMap, + nodePackageNameOf, + owningPackage, + resolveNodeWorkspaceImport, + substituteStar, +} from '../../import-resolvers/node-workspace-packages.js'; +import { resolveFile } from './file-candidates.js'; +import { tsconfigFor, type TsconfigIndex, type TsPathMapping } from './tsconfig.js'; + +export interface TsModuleResolutionContext { + readonly fromFile: string; + readonly allFilePaths: ReadonlySet; + readonly tsconfigs: TsconfigIndex | null; + readonly workspacePackages: NodeWorkspacePackages | null; +} + +/** + * Resolve one specifier to a repo file, or `null` when nothing in the repo + * declares it. + */ +export function resolveTsModule(specifier: string, ctx: TsModuleResolutionContext): string | null { + if (specifier === '') return null; + + // 1. A path specifier. + if (specifier.startsWith('.')) { + const joined = joinFrom(ctx.fromFile, specifier); + return joined === null ? null : resolveFile(joined, ctx.allFilePaths); + } + if (specifier.startsWith('/')) { + return resolveFile(specifier.slice(1), ctx.allFilePaths); + } + + // 2. Package-internal `#imports`. Scoped to the importing package, so it is + // looked up there and nowhere else — a `#` specifier that the package does + // not declare is an error in Node, not a repo-wide search. + if (specifier.startsWith('#')) { + return resolveSubpathImport(specifier, ctx); + } + + const config = tsconfigFor(ctx.tsconfigs, ctx.fromFile); + + // 3. `paths`, longest matching pattern first. + if (config !== null && config.paths.length > 0) { + const viaPaths = resolveViaPaths(specifier, config.paths, ctx.allFilePaths); + if (viaPaths !== null) return viaPaths; + } + + // 4. `baseUrl`. + if (config !== null && config.baseUrl !== null) { + const viaBaseUrl = resolveFile(joinRepo(config.baseUrl, specifier), ctx.allFilePaths); + if (viaBaseUrl !== null) return viaBaseUrl; + } + + // 5. A package that lives in this repo. + const viaWorkspace = resolveNodeWorkspaceImport( + specifier, + ctx.workspacePackages, + ctx.allFilePaths, + ); + if (viaWorkspace !== null) return viaWorkspace; + + // 6. External. Nothing in the repo declared it, so it resolves to nothing — + // which for a registry dependency is the correct and complete answer. + return null; +} + +/** + * Apply `paths` the way tsc does: the pattern with the longest literal prefix + * before `*` wins, and its targets are tried in declaration order. + * + * The old loader kept `targets[0]` and treated the pattern as a plain prefix, + * which silently mis-resolves the common `"@/*": ["./src/*", "./generated/*"]` + * shape — the second target is where half of a generated-code monorepo lives. + */ +function resolveViaPaths( + specifier: string, + paths: readonly TsPathMapping[], + allFiles: ReadonlySet, +): string | null { + const matches: { mapping: TsPathMapping; stem: string | null; prefixLength: number }[] = []; + + for (const mapping of paths) { + const star = mapping.pattern.indexOf('*'); + if (star === -1) { + if (mapping.pattern === specifier) { + matches.push({ mapping, stem: null, prefixLength: mapping.pattern.length }); + } + continue; + } + const prefix = mapping.pattern.slice(0, star); + const suffix = mapping.pattern.slice(star + 1); + if (!specifier.startsWith(prefix) || !specifier.endsWith(suffix)) continue; + if (specifier.length < prefix.length + suffix.length) continue; + matches.push({ + mapping, + stem: specifier.slice(prefix.length, specifier.length - suffix.length), + prefixLength: prefix.length, + }); + } + + // An exact (starless) pattern outranks any wildcard, THEN longer prefix wins. + // Sorting on prefix length alone left that first rule to luck: `a` and `a*` + // both match `a` with prefix length 1, so whichever was declared first won. + matches.sort( + (a, b) => Number(a.stem !== null) - Number(b.stem !== null) || b.prefixLength - a.prefixLength, + ); + + for (const match of matches) { + for (const target of match.mapping.targets) { + const candidate = match.stem === null ? target : substituteStar(target, match.stem); + const resolved = resolveFile(candidate, allFiles); + if (resolved !== null) return resolved; + } + } + return null; +} + +/** Resolve `#name` against the importing file's own package manifest. */ +function resolveSubpathImport(specifier: string, ctx: TsModuleResolutionContext): string | null { + const packages = ctx.workspacePackages; + if (packages === null) return null; + const owner = owningPackage(ctx.fromFile, packages); + if (owner === null) return null; + // `imports` takes pattern keys (`"#internal/*"`) exactly like `exports`, so + // it gets the same matcher rather than an exact lookup. + for (const stem of matchSubpathMap(owner.subpathImports, specifier) ?? []) { + const resolved = resolveFile(stem, ctx.allFilePaths); + if (resolved !== null) return resolved; + } + return null; +} + +/** + * Resolve a relative specifier against the importing file's directory, or + * `null` when it climbs out of the repository. + * + * Popping an empty segment list would silently CLAMP at the root, so + * `../../../secret` from `src/main.ts` became `secret` and could resolve a + * repo-root file the specifier never named. Outside the repo there is nothing + * indexed to resolve to, so the honest answer is nothing. + */ +function joinFrom(fromFile: string, specifier: string): string | null { + const segments = fromFile.split('/').slice(0, -1); + for (const part of specifier.split('/')) { + if (part === '.' || part === '') continue; + if (part === '..') { + if (segments.length === 0) return null; + segments.pop(); + } else { + segments.push(part); + } + } + return segments.join('/'); +} + +function joinRepo(dir: string, rest: string): string { + return dir === '' ? rest : `${dir}/${rest}`; +} + +/** Whether a specifier names a package rather than a path — used by callers + * that want to report an unresolved import as external rather than missing. */ +export function isPackageSpecifier(specifier: string): boolean { + return nodePackageNameOf(specifier) !== null; +} diff --git a/gitnexus/src/core/ingestion/languages/typescript/query.ts b/gitnexus/src/core/ingestion/languages/typescript/query.ts index f2d2f22cf..babdb273b 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/query.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/query.ts @@ -175,17 +175,20 @@ export const TYPESCRIPT_SCOPE_QUERY = ` ;; to no label and TypeScript aliases produced NO scope-resolution def at all. ;; Kotlin and Dart already spell it this way. (type_alias_declaration - name: (type_identifier) @declaration.name) @declaration.type_alias + name: (type_identifier) @declaration.name + type_parameters: (type_parameters)? @declaration.type-parameters) @declaration.type_alias (internal_module name: (identifier) @declaration.name) @declaration.namespace ;; Declarations — methods / functions / constructors (function_declaration - name: (identifier) @declaration.name) @declaration.function + name: (identifier) @declaration.name + type_parameters: (type_parameters)? @declaration.type-parameters) @declaration.function (generator_function_declaration - name: (identifier) @declaration.name) @declaration.function + name: (identifier) @declaration.name + type_parameters: (type_parameters)? @declaration.type-parameters) @declaration.function ;; Function overload signatures (declaration-only; body in a separate ;; function_declaration). Extractors dedup by (name, parameterTypes). diff --git a/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts index fe7a30da4..bc2af4bcb 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts @@ -21,15 +21,13 @@ import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolv import { simpleKey } from '../../scope-resolution/graph-bridge/node-lookup.js'; import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; import { typescriptProvider } from '../typescript.js'; -import { loadTsconfigPaths, type TsconfigPaths } from '../../language-config.js'; -import { buildSuffixIndex, type SuffixIndex } from '../../import-resolvers/utils.js'; -import { indexOnlyElementType } from '../../type-extractors/shared.js'; +import { loadTsconfigIndex, type TsconfigIndex } from './tsconfig.js'; import { - typescriptArityCompatibility, - typescriptMergeBindings, - resolveTsTarget, - type TsResolveContext, -} from './index.js'; + loadNodeWorkspacePackages, + type NodeWorkspacePackages, +} from '../../import-resolvers/node-workspace-packages.js'; +import { indexOnlyElementType } from '../../type-extractors/shared.js'; +import { typescriptArityCompatibility, typescriptMergeBindings, resolveTsTarget } from './index.js'; import { getNuxtAutoImportEntry, hasNuxtAutoImports, @@ -39,7 +37,10 @@ import { /** Shape the orchestrator threads in via `RunScopeResolutionInput.resolutionConfig`. */ interface TypescriptResolutionConfig { - readonly tsconfigPaths: TsconfigPaths | null; + /** Every tsconfig in the repo, `extends` resolved (#2953). */ + readonly tsconfigs: TsconfigIndex | null; + /** Every in-repo `package.json`, for workspace-package resolution (#2953). */ + readonly nodeWorkspacePackages: NodeWorkspacePackages | null; /** Nuxt/Nitro auto-import map. Null for non-Nuxt projects. */ readonly nuxtAutoImports: NuxtAutoImportConfig | null; } @@ -55,54 +56,22 @@ const TYPESCRIPT_TYPE_ONLY_BINDING_TYPES = new Set([ ]); /** - * Build a `resolveImportTarget` adapter that memoizes the workspace - * file list, the lower-cased file list, and the per-pass `resolveCache` - * across every import lookup in a single workspace pass. The - * orchestrator passes the same `ReadonlySet` reference for every call - * within a pass — we use that identity to detect when the workspace - * changes and recompute the derived state lazily. + * Build the `resolveImportTarget` adapter. * - * Without this memoization, `resolveTsTarget` re-derived - * `allFileList` and `normalizedFileList` (both O(N_files)) and threw - * away the `resolveCache` on every import — O(N_files × N_imports) - * total work for what should be O(N_files + N_imports). + * No per-file-set memo any more: the suffix index it existed to amortize is + * gone with #2953. Real resolution derives nothing from the file list — every + * candidate comes from a config the repo declares, and checking one is a + * `Set.has` — so there is nothing left to cache per pass. */ function makeTsResolveImportTarget(): ScopeResolver['resolveImportTarget'] { - interface PassCache { - readonly key: ReadonlySet; - readonly allFilePaths: Set; - readonly allFileList: readonly string[]; - readonly normalizedFileList: readonly string[]; - readonly index: SuffixIndex; - readonly resolveCache: Map; - } - let cached: PassCache | null = null; - return (targetRaw, fromFile, allFilePaths, resolutionConfig) => { - if (cached === null || cached.key !== allFilePaths) { - const allFileList = Array.from(allFilePaths); - const normalizedFileList = allFileList.map((f) => f.toLowerCase()); - cached = { - key: allFilePaths, - allFilePaths: new Set(allFilePaths), - allFileList, - normalizedFileList, - index: buildSuffixIndex(normalizedFileList, allFileList), - resolveCache: new Map(), - }; - } - const cfg = resolutionConfig as TypescriptResolutionConfig | undefined; - const ws: TsResolveContext = { + return resolveTsTarget(targetRaw, { fromFile, - allFilePaths: cached.allFilePaths, - allFileList: cached.allFileList, - normalizedFileList: cached.normalizedFileList, - index: cached.index, - resolveCache: cached.resolveCache, - tsconfigPaths: cfg?.tsconfigPaths ?? null, - }; - return resolveTsTarget(targetRaw, ws); + allFilePaths, + tsconfigs: cfg?.tsconfigs ?? null, + nodeWorkspacePackages: cfg?.nodeWorkspacePackages ?? null, + }); }; } @@ -128,7 +97,8 @@ const typescriptScopeResolver: ScopeResolver = { // `nuxtAutoImports` is null for non-Nuxt projects (no .nuxt/imports.d.ts), // so this adds zero overhead to ordinary TypeScript repos. loadResolutionConfig: async (repoPath: string) => ({ - tsconfigPaths: await loadTsconfigPaths(repoPath), + tsconfigs: await loadTsconfigIndex(repoPath), + nodeWorkspacePackages: await loadNodeWorkspacePackages(repoPath), nuxtAutoImports: await loadNuxtAutoImports(repoPath), }), diff --git a/gitnexus/src/core/ingestion/languages/typescript/tsconfig.ts b/gitnexus/src/core/ingestion/languages/typescript/tsconfig.ts new file mode 100644 index 000000000..0e9871c64 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/typescript/tsconfig.ts @@ -0,0 +1,338 @@ +/** + * Real `tsconfig.json` loading for module resolution (#2953). + * + * The previous loader (`language-config.ts:loadTsconfigPaths`) was built to feed + * a heuristic, and it shows: it reads three filenames at the repo ROOT only, + * gives up unless `compilerOptions.paths` exists, keeps only `targets[0]` of + * each mapping, and treats a pattern as a plain prefix. That is enough to make + * a guess look plausible and not enough to resolve anything correctly: + * + * - a monorepo has one tsconfig PER PACKAGE, and `apps/web/tsconfig.json` is + * what governs `apps/web/src/main.ts` — the root config governs nothing; + * - `extends` is how essentially every real config is written, and the + * `baseUrl` / `paths` almost always live in the extended base; + * - `baseUrl` alone (no `paths`) is a complete resolution rule on its own, and + * it is exactly the rule that makes `import 'src/utils/foo'` legal — the + * case the old suffix matcher was really standing in for; + * - `paths` maps a pattern to an ORDERED LIST of targets, tried in order. + * + * So this module answers the question TypeScript actually asks: for THIS file, + * what are `baseUrl` and `paths`? + */ + +import fs from 'fs/promises'; +import path from 'path'; + +import { isHardcodedIgnoredDirectoryAtPath } from '../../../../config/ignore-service.js'; +import { logger } from '../../../logger.js'; + +/** One `paths` entry, pattern and targets kept in declaration order. */ +export interface TsPathMapping { + /** The pattern as written, e.g. `@/*`, `@app/*`, `exact`. */ + readonly pattern: string; + /** Targets as written, relative to `baseUrl`. Tried in order. */ + readonly targets: readonly string[]; +} + +/** The resolution-relevant part of one resolved tsconfig. */ +export interface TsconfigScope { + /** Repo-relative directory the config governs (the tsconfig's own directory). */ + readonly dir: string; + /** + * Repo-relative `baseUrl`, or `null` when the config declares none. + * + * `null` is not the same as `'.'`: without `baseUrl`, TypeScript does NOT + * resolve non-relative specifiers against the project at all (they are + * package lookups), and `paths` targets are resolved against the tsconfig's + * own directory instead. + */ + readonly baseUrl: string | null; + readonly paths: readonly TsPathMapping[]; +} + +/** Every tsconfig in the repo, indexed so the nearest one to a file wins. */ +export interface TsconfigIndex { + /** Deepest-first, so the first `dir` that prefixes a file path governs it. */ + readonly scopes: readonly TsconfigScope[]; +} + +const SCAN_MAX_DIRS = 20_000; +const SCAN_MAX_DEPTH = 24; +/** Guard against an `extends` cycle or a pathological chain. */ +const MAX_EXTENDS_DEPTH = 16; + +/** + * The config governing `filePath` — the nearest tsconfig at or above it. + * + * TypeScript resolves a file against the project that includes it; the nearest + * enclosing tsconfig is the faithful approximation of that without evaluating + * `include`/`exclude` globs, and it is what makes a monorepo's per-package + * `baseUrl` apply to that package's files instead of the root's. + */ +export function tsconfigFor(index: TsconfigIndex | null, filePath: string): TsconfigScope | null { + if (index === null) return null; + for (const scope of index.scopes) { + if (scope.dir === '') return scope; + if (filePath.startsWith(`${scope.dir}/`)) return scope; + } + return null; +} + +/** Load every tsconfig in the repo, resolving `extends` chains. */ +export async function loadTsconfigIndex(repoRoot: string): Promise { + const files = await findTsconfigFiles(repoRoot); + if (files.length === 0) return null; + + const ranked: { scope: TsconfigScope; rank: number }[] = []; + for (const absPath of files) { + const options = await readCompilerOptions(absPath, 0); + if (options === null) continue; + // `readCompilerOptions` resolves both to ABSOLUTE paths against whichever + // config in the `extends` chain declared them, which is the only way the + // chain stays unambiguous. Rebasing to repo-relative happens once, here. + const baseUrl = options.baseUrl === undefined ? null : repoRelative(repoRoot, options.baseUrl); + const paths = (options.paths ?? []).map((mapping) => ({ + pattern: mapping.pattern, + targets: mapping.targets.map((t) => rebaseTarget(repoRoot, t)), + })); + // A config declaring NEITHER is kept, not skipped. Dropping it let + // `tsconfigFor` fall through to an enclosing config, so a package whose own + // tsconfig declares no `baseUrl` — meaning its non-relative specifiers are + // package lookups — silently inherited the repo root's aliases instead. + // An empty scope is the accurate answer for such a file, and only a scope + // can express it. + ranked.push({ + scope: { dir: repoRelative(repoRoot, path.dirname(absPath)), baseUrl, paths }, + rank: configRank(path.basename(absPath)), + }); + } + if (ranked.length === 0) return null; + + // Deepest first, because `tsconfigFor` takes the first match and it must be + // the most specific config rather than whichever the walk reached first. + // + // Then by filename rank WITHIN a directory, which is the half that is easy to + // miss: `tsconfig.json` and `tsconfig.base.json` routinely sit side by side, + // and the base exists to be extended, not to govern. Reading whichever the + // directory listing returned first made a config's own `paths` invisible + // whenever its base happened to be listed earlier. + ranked.sort((a, b) => b.scope.dir.length - a.scope.dir.length || a.rank - b.rank); + return { scopes: ranked.map((entry) => entry.scope) }; +} + +/** + * Precedence among configs sharing a directory: the project config governs, and + * everything else is a base or a variant that exists to be extended. + */ +function configRank(fileName: string): number { + if (fileName === 'tsconfig.json') return 0; + if (fileName === 'jsconfig.json') return 1; + return 2; +} + +/** Resolved compiler options, rebased to repo-relative paths. */ +interface ResolvedOptions { + baseUrl?: string; + paths?: TsPathMapping[]; +} + +/** + * Read one tsconfig and merge in whatever it `extends`. + * + * Rebasing happens per FILE, before merging, because `extends` does not rebase + * `baseUrl`: a base config at `configs/tsconfig.base.json` declaring + * `"baseUrl": "."` means `configs/`, even when extended from `apps/web`. Doing + * the rebase at read time is what keeps that true through the chain. + */ +async function readCompilerOptions( + absPath: string, + depth: number, + repoRootHint?: string, +): Promise { + if (depth > MAX_EXTENDS_DEPTH) { + logger.warn(`[typescript] tsconfig extends chain too deep at ${absPath}; ignoring the rest`); + return null; + } + + let parsed: Record; + try { + parsed = parseJsonc(await fs.readFile(absPath, 'utf-8')); + } catch { + return null; + } + + const dir = path.dirname(absPath); + // Read what this config extends FIRST: `paths` targets resolve against the + // EFFECTIVE `baseUrl`, which a config declaring `paths` alone inherits from + // its base. Resolving them against this config's own directory instead would + // load the right alias pattern and point every target at the wrong place. + const inherited = await readExtended(parsed.extends, dir, depth, repoRootHint); + + const own: ResolvedOptions = {}; + const compilerOptions = parsed.compilerOptions; + if (compilerOptions !== null && typeof compilerOptions === 'object') { + const opts = compilerOptions as Record; + if (typeof opts.baseUrl === 'string') { + own.baseUrl = path.resolve(dir, opts.baseUrl); + } + if (opts.paths !== null && typeof opts.paths === 'object' && !Array.isArray(opts.paths)) { + // tsc resolves `paths` targets against the effective `baseUrl` — this + // config's own if it declares one, otherwise the inherited one — and + // against the config's own directory only when neither exists. Doing it + // here, per file, is what keeps an `extends` chain unambiguous: by the + // time these merge, every target is already absolute. + const pathsBase = own.baseUrl ?? inherited?.baseUrl ?? dir; + own.paths = []; + for (const [pattern, targets] of Object.entries(opts.paths as Record)) { + if (!Array.isArray(targets)) continue; + const asStrings = targets + .filter((t): t is string => typeof t === 'string') + .map((t) => path.resolve(pathsBase, t)); + if (asStrings.length > 0) own.paths.push({ pattern, targets: asStrings }); + } + } + } + + // Own options win over inherited ones — that is what `extends` means. `paths` + // is replaced wholesale rather than merged, matching tsc. + return { + ...(inherited ?? {}), + ...own, + }; +} + +/** Follow `extends`, which may be a string or (TS 5+) an array, base-first. */ +async function readExtended( + value: unknown, + fromDir: string, + depth: number, + repoRootHint?: string, +): Promise { + const specs = typeof value === 'string' ? [value] : Array.isArray(value) ? value : []; + let merged: ResolvedOptions | null = null; + for (const spec of specs) { + if (typeof spec !== 'string') continue; + const resolved = await resolveExtendsTarget(spec, fromDir); + if (resolved === null) continue; + const options = await readCompilerOptions(resolved, depth + 1, repoRootHint); + if (options === null) continue; + // Later entries win over earlier ones, per tsc's array semantics. + merged = { ...(merged ?? {}), ...options }; + } + return merged; +} + +/** + * An `extends` value is either a path or a package name. + * + * The package form (`"extends": "@tsconfig/node20/tsconfig.json"`, + * `"@acme/tsconfig"`) lives in `node_modules`, which this tool deliberately + * does NOT index — it is dependency code, not the repository's own. But not + * indexing it is different from not READING it, and the distinction matters + * here: a shared internal base config is exactly where a monorepo puts the + * `paths` its packages import through, so refusing to open it loses aliases + * that the repository genuinely declares. + * + * So the file is read from disk when it is there, walking `node_modules` up + * from the extending config the way Node does. When it is absent — an + * un-installed checkout, which is a shape a static analyser must expect and a + * compiler may refuse — the answer is `null`, and the caller keeps whatever the + * extending config declared itself. That degrades to fewer resolutions, never + * to invented ones. + */ +async function resolveExtendsTarget(spec: string, fromDir: string): Promise { + if (spec.startsWith('.') || path.isAbsolute(spec)) { + return firstReadableConfig(path.resolve(fromDir, spec)); + } + for (const modulesDir of nodeModulesChain(fromDir)) { + const found = await firstReadableConfig(path.join(modulesDir, spec)); + if (found !== null) return found; + } + return null; +} + +/** `/node_modules`, then each ancestor's, the way Node resolves. */ +function* nodeModulesChain(fromDir: string): Generator { + let dir = fromDir; + for (;;) { + if (path.basename(dir) !== 'node_modules') yield path.join(dir, 'node_modules'); + const parent = path.dirname(dir); + if (parent === dir) return; + dir = parent; + } +} + +/** The first spelling of `base` that is a readable file. */ +async function firstReadableConfig(base: string): Promise { + for (const candidate of [base, `${base}.json`, path.join(base, 'tsconfig.json')]) { + try { + const stat = await fs.stat(candidate); + if (stat.isFile()) return candidate; + } catch { + // try the next spelling + } + } + return null; +} + +async function findTsconfigFiles(repoRoot: string): Promise { + const found: string[] = []; + const queue: { dir: string; depth: number }[] = [{ dir: repoRoot, depth: 0 }]; + let dirsScanned = 0; + + while (queue.length > 0 && dirsScanned < SCAN_MAX_DIRS) { + const { dir, depth } = queue.shift()!; + dirsScanned++; + let entries: import('fs').Dirent[]; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + if (entry.isDirectory()) { + const childDir = path.join(dir, entry.name); + if (isHardcodedIgnoredDirectoryAtPath(repoRoot, childDir)) continue; + if (depth < SCAN_MAX_DEPTH) queue.push({ dir: childDir, depth: depth + 1 }); + continue; + } + if (!entry.isFile()) continue; + // `tsconfig.json`, `tsconfig.app.json`, `jsconfig.json`, … — any of them + // can carry the `baseUrl`/`paths` that governs its directory. + if (/^(ts|js)config(\..+)?\.json$/.test(entry.name)) { + found.push(path.join(dir, entry.name)); + } + } + } + return found; +} + +/** Strip comments and trailing commas — tsconfig is JSONC, not JSON. */ +function parseJsonc(raw: string): Record { + const withoutComments = raw + .replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*$)|(\/\*[\s\S]*?\*\/)/gm, (match, line, block) => + line !== undefined || block !== undefined ? '' : match, + ) + .replace(/,(\s*[}\]])/g, '$1'); + return JSON.parse(withoutComments) as Record; +} + +function repoRelative(repoRoot: string, absDir: string): string { + const rel = path.relative(repoRoot, absDir).split(path.sep).join('/'); + return rel === '.' || rel === '' ? '' : rel; +} + +/** + * A `paths` target rebased to repo-relative, keeping any trailing `*`. + * + * `path.resolve` swallows the wildcard into a path segment, so it is stripped + * before resolving and re-appended after — the `*` is a substitution marker, + * not a directory named `*`. + */ +function rebaseTarget(repoRoot: string, absTarget: string): string { + // `/repo/src/*` must come back as `src/*`, not `src*`: stripping only the + // star leaves a trailing slash that `path.relative` then eats. + const suffix = absTarget.endsWith('/*') ? '/*' : absTarget.endsWith('*') ? '*' : ''; + const base = suffix === '' ? absTarget : absTarget.slice(0, -suffix.length); + return `${repoRelative(repoRoot, base)}${suffix}`; +} diff --git a/gitnexus/src/core/ingestion/languages/vue/import-target.ts b/gitnexus/src/core/ingestion/languages/vue/import-target.ts index a16877459..886580b0e 100644 --- a/gitnexus/src/core/ingestion/languages/vue/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/vue/import-target.ts @@ -1,81 +1,36 @@ /** * Import-target resolver for Vue SFCs (RFC #909 Ring 3, issue #940). * - * Vue `